diff --git a/documentation/api/definitions.md b/documentation/api/definitions.md index dff4e708bfd..aa2129135e1 100644 --- a/documentation/api/definitions.md +++ b/documentation/api/definitions.md @@ -351,6 +351,16 @@ The `uid` property is useful for uniquely identifying a process when it is runni "processArchitecture": "x64" } ``` +## TraceEventFilter + +Object describing a filter for trace events. + +| Name | Type | Description | +|---|---|---| +| `ProviderName` | string | The event provider that will produce the specified event. | +| `EventName` | string | The name of the event, which is a concatenation of the task name and opcode name, if any. The task and opcode names are separated by a '/'. If the event has no opcode, then the event name is just the task name. | +| `PayloadFilter` | map (of string) | (Optional) A mapping of event payload field names to their expected value. A subset of the payload fields may be specified. | + ## TraceProfile diff --git a/documentation/configuration.md b/documentation/configuration.md index 916c2678c95..70c8b41e10d 100644 --- a/documentation/configuration.md +++ b/documentation/configuration.md @@ -1421,6 +1421,7 @@ An action that collects a trace of the process that the collection rule is targe | `BufferSizeMegabytes` | int | false | The size (in megabytes) of the event buffer used in the runtime. If the event buffer is filled, events produced by event providers may be dropped until the buffer is cleared. Increase the buffer size to mitigate this or pair down the list of event providers, keywords, and level to filter out extraneous events. Only applies when `Providers` is specified. | `256` | `1` | `1024` | | `Duration` | TimeSpan? | false | The duration of the trace operation. | `"00:00:30"` (30 seconds) | `"00:00:01"` (1 second) | `"1.00:00:00"` (1 day) | | `Egress` | string | true | The named [egress provider](egress.md) for egressing the collected trace. | | | | +| `StoppingEvent` | [TraceEventFilter](api/definitions.md#traceeventfilter)? | false | The event to watch for while collecting the trace, and once either the event is hit or the `Duration` is reached the trace will be stopped. This can only be specified if `Providers` is set. | `null` | | | ##### Outputs diff --git a/documentation/schema.json b/documentation/schema.json index 1ee52bbef3b..55c3122aee6 100644 --- a/documentation/schema.json +++ b/documentation/schema.json @@ -1722,6 +1722,17 @@ "type": "string", "description": "The name of the egress provider to which the trace is egressed.", "minLength": 1 + }, + "StoppingEvent": { + "description": "The event to watch for while collecting the trace, and once observed the trace will be stopped.", + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/definitions/TraceEventFilter" + } + ] } } }, @@ -1793,6 +1804,36 @@ "Verbose" ] }, + "TraceEventFilter": { + "type": "object", + "additionalProperties": false, + "required": [ + "ProviderName", + "EventName" + ], + "properties": { + "ProviderName": { + "type": "string", + "description": "The event provider that will produce the specified event.", + "minLength": 1 + }, + "EventName": { + "type": "string", + "description": "The name of the event, which is a concatenation of the task name and opcode name, if any. The task and opcode names are separated by a '/'. If the event has no opcode, then the event name is just the task name.", + "minLength": 1 + }, + "PayloadFilter": { + "type": [ + "null", + "object" + ], + "description": "A mapping of event payload field names to their expected value. A subset of the payload fields may be specified.", + "additionalProperties": { + "type": "string" + } + } + } + }, "ExecuteOptions": { "type": "object", "additionalProperties": false, diff --git a/src/Microsoft.Diagnostics.Monitoring.Options/OptionsDisplayStrings.Designer.cs b/src/Microsoft.Diagnostics.Monitoring.Options/OptionsDisplayStrings.Designer.cs index c8c368690c4..e4bffb6763a 100644 --- a/src/Microsoft.Diagnostics.Monitoring.Options/OptionsDisplayStrings.Designer.cs +++ b/src/Microsoft.Diagnostics.Monitoring.Options/OptionsDisplayStrings.Designer.cs @@ -715,6 +715,15 @@ public static string DisplayAttributeDescription_CollectTraceOptions_RequestRund } } + /// + /// Looks up a localized string similar to The event to watch for while collecting the trace, and once observed the trace will be stopped.. + /// + public static string DisplayAttributeDescription_CollectTraceOptions_StoppingEvent { + get { + return ResourceManager.GetString("DisplayAttributeDescription_CollectTraceOptions_StoppingEvent", resourceCulture); + } + } + /// /// Looks up a localized string similar to Buffer size used when copying data from an egress callback returning a stream to the egress callback that is provided a stream to which data is written.. /// @@ -1391,6 +1400,33 @@ public static string DisplayAttributeDescription_ThreadpoolQueueLengthOptions_Le } } + /// + /// Looks up a localized string similar to The name of the event, which is a concatenation of the task name and opcode name, if any. The task and opcode names are separated by a '/'. If the event has no opcode, then the event name is just the task name.. + /// + public static string DisplayAttributeDescription_TraceEventFilter_EventName { + get { + return ResourceManager.GetString("DisplayAttributeDescription_TraceEventFilter_EventName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A mapping of event payload field names to their expected value. A subset of the payload fields may be specified.. + /// + public static string DisplayAttributeDescription_TraceEventFilter_PayloadFilter { + get { + return ResourceManager.GetString("DisplayAttributeDescription_TraceEventFilter_PayloadFilter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The event provider that will produce the specified event.. + /// + public static string DisplayAttributeDescription_TraceEventFilter_ProviderName { + get { + return ResourceManager.GetString("DisplayAttributeDescription_TraceEventFilter_ProviderName", resourceCulture); + } + } + /// /// Looks up a localized string similar to The {0} field, {1} field, or {2} field is required.. /// diff --git a/src/Microsoft.Diagnostics.Monitoring.Options/OptionsDisplayStrings.resx b/src/Microsoft.Diagnostics.Monitoring.Options/OptionsDisplayStrings.resx index 68cba82be5f..c5c6e2fcd41 100644 --- a/src/Microsoft.Diagnostics.Monitoring.Options/OptionsDisplayStrings.resx +++ b/src/Microsoft.Diagnostics.Monitoring.Options/OptionsDisplayStrings.resx @@ -729,4 +729,20 @@ The name of the egress provider to which the call stacks are egressed. + + The event to watch for while collecting the trace, and once observed the trace will be stopped. + The description provided for the StoppingEvent parameter on CollectTraceOptions. + + + The name of the event, which is a concatenation of the task name and opcode name, if any. The task and opcode names are separated by a '/'. If the event has no opcode, then the event name is just the task name. + The description provided for the EventName parameter on TraceEventFilter. + + + The event provider that will produce the specified event. + The description provided for the ProviderName parameter on TraceEventFilter. + + + A mapping of event payload field names to their expected value. A subset of the payload fields may be specified. + The description provided for the PayloadFilter parameter on TraceEventFilter. + \ No newline at end of file diff --git a/src/Microsoft.Diagnostics.Monitoring.WebApi/EventMonitoringPassthroughStream.cs b/src/Microsoft.Diagnostics.Monitoring.WebApi/EventMonitoringPassthroughStream.cs new file mode 100644 index 00000000000..7312162b2cb --- /dev/null +++ b/src/Microsoft.Diagnostics.Monitoring.WebApi/EventMonitoringPassthroughStream.cs @@ -0,0 +1,260 @@ +// 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.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Diagnostics.Monitoring.WebApi +{ + /// + /// A stream that can monitor an event stream which is compatible with + /// for a specific event while also passing along the event data to a destination stream. + /// + public sealed class EventMonitoringPassthroughStream : Stream + { + private readonly Action _onPayloadFilterMismatch; + private readonly Action _onEvent; + private readonly bool _callOnEventOnlyOnce; + + private readonly Stream _sourceStream; + private readonly Stream _destinationStream; + private EventPipeEventSource _eventSource; + + private readonly string _providerName; + private readonly string _eventName; + + // The original payload filter of fieldName->fieldValue specified by the user. It will only be used to hydrate _payloadFilterIndexCache. + private readonly IDictionary _payloadFilter; + + // This tracks the exact indices into the provided event's payload to check for the expected values instead + // of repeatedly searching the payload for the field names in _payloadFilter. + private Dictionary _payloadFilterIndexCache; + + + /// + /// A stream that can monitor an event stream which is compatible with + /// for a specific event while also passing along the event data to a destination stream. + /// + /// The stopping event provider name. + /// The stopping event name, which is the concatenation of the task name and opcode name, if set. for more information about the format. + /// A mapping of the stopping event payload field names to their expected values. A subset of the payload fields may be specified. + /// A callback that will be invoked each time the requested event has been observed. + /// A callback that will be invoked if the field names specified in do not match those in the event's manifest. + /// The source event stream which is compatible with . + /// The destination stream to write events. It must either be full duplex or be write-only. + /// The size of the buffer to use when writing to the . + /// If true, the provided will only be called for the first matching event. + /// If true, the provided will not be automatically closed when this class is. + public EventMonitoringPassthroughStream( + string providerName, + string eventName, + IDictionary payloadFilter, + Action onEvent, + Action onPayloadFilterMismatch, + Stream sourceStream, + Stream destinationStream, + int bufferSize, + bool callOnEventOnlyOnce, + bool leaveDestinationStreamOpen) : base() + { + _providerName = providerName; + _eventName = eventName; + _onEvent = onEvent; + _onPayloadFilterMismatch = onPayloadFilterMismatch; + _sourceStream = sourceStream; + _payloadFilter = payloadFilter; + _callOnEventOnlyOnce = callOnEventOnlyOnce; + + // Wrap a buffered stream around the destination stream + // to avoid slowing down the event processing with the data + // passthrough unless there is significant pressure. + _destinationStream = new BufferedStream( + leaveDestinationStreamOpen + ? new StreamLeaveOpenWrapper(destinationStream) + : destinationStream, + bufferSize); + } + + /// + /// Start processing the event stream, monitoring it for the requested event and transferring its data to the specified destination stream. + /// This will continue to run until the event stream is complete or a stop is requested, regardless of if the requested event has been observed. + /// + /// The cancellation token. + /// + public Task ProcessAsync(CancellationToken token) + { + return Task.Run(() => + { + _eventSource = new EventPipeEventSource(this); + token.ThrowIfCancellationRequested(); + using IDisposable registration = token.Register(() => _eventSource.Dispose()); + + _eventSource.Dynamic.AddCallbackForProviderEvent(_providerName, _eventName, TraceEventCallback); + + // The EventPipeEventSource will drive the transferring of data to the destination stream as it processes events. + _eventSource.Process(); + token.ThrowIfCancellationRequested(); + }, token); + } + + /// + /// Stops monitoring for the specified stopping event. Data will continue to be written to the provided destination stream. + /// + private void StopMonitoringForEvent() + { + _eventSource?.Dynamic.RemoveCallback(TraceEventCallback); + } + + private void TraceEventCallback(TraceEvent obj) + { + if (_payloadFilterIndexCache == null && !HydratePayloadFilterCache(obj)) + { + // The payload filter doesn't map onto the actual data, + // we'll never match the event so stop checking it + // and proceed with just transferring the data to the destination stream. + StopMonitoringForEvent(); + _onPayloadFilterMismatch(obj); + return; + } + + if (!DoesPayloadMatch(obj)) + { + return; + } + + if (_callOnEventOnlyOnce) + { + StopMonitoringForEvent(); + } + + _onEvent(obj); + } + + /// + /// Hydrates the payload filter cache. + /// + /// An instance of the stopping event (matching provider, task name, and opcode), but without checking the payload yet. + /// + private bool HydratePayloadFilterCache(TraceEvent obj) + { + if (_payloadFilterIndexCache != null) + { + return true; + } + + // If there's no payload filter, there's nothing to do. + if (_payloadFilter == null || _payloadFilter.Count == 0) + { + _payloadFilterIndexCache = new Dictionary(capacity: 0); + return true; + } + + // If the payload has fewer fields than the requested filter, we can never match it. + // NOTE: this function will only ever be called with an instance of the stopping event + // (matching provider, task name, and opcode) but without checking the payload yet. + if (obj.PayloadNames.Length < _payloadFilter.Count) + { + return false; + } + + Dictionary payloadFilterCache = new(capacity: _payloadFilter.Count); + for (int i = 0; (i < obj.PayloadNames.Length) && (payloadFilterCache.Count < _payloadFilter.Count); i++) + { + if (_payloadFilter.TryGetValue(obj.PayloadNames[i], out string payloadValue)) + { + payloadFilterCache.Add(i, payloadValue); + } + } + + // Check if one or more of the requested filter field names did not exist on the actual payload. + if (_payloadFilter.Count != payloadFilterCache.Count) + { + return false; + } + + _payloadFilterIndexCache = payloadFilterCache; + + return true; + } + + private bool DoesPayloadMatch(TraceEvent obj) + { + foreach (var (fieldIndex, expectedValue) in _payloadFilterIndexCache) + { + string fieldValue = Convert.ToString(obj.PayloadValue(fieldIndex), CultureInfo.InvariantCulture) ?? string.Empty; + if (!string.Equals(fieldValue, expectedValue, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + public override int Read(byte[] buffer, int offset, int count) + { + return Read(buffer.AsSpan(offset, count)); + } + + public override int Read(Span buffer) + { + int bytesRead = _sourceStream.Read(buffer); + if (bytesRead != 0) + { + _destinationStream.Write(buffer[..bytesRead]); + } + + return bytesRead; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + int bytesRead = await _sourceStream.ReadAsync(buffer, cancellationToken); + if (bytesRead != 0) + { + await _destinationStream.WriteAsync(buffer[..bytesRead], cancellationToken); + } + + return bytesRead; + } + + public override bool CanSeek => false; + public override bool CanWrite => false; + + public override bool CanTimeout => _sourceStream.CanRead; + public override bool CanRead => _sourceStream.CanRead; + public override long Length => _sourceStream.Length; + + public override long Position { get => _sourceStream.Position; set => _sourceStream.Position = value; } + public override int ReadTimeout { get => _sourceStream.ReadTimeout; set => _sourceStream.ReadTimeout = value; } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override void CopyTo(Stream destination, int bufferSize) => throw new NotSupportedException(); + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) => throw new NotSupportedException(); + + public override void Flush() => _destinationStream.Flush(); + public override Task FlushAsync(CancellationToken cancellationToken) => _destinationStream.FlushAsync(cancellationToken); + + public override async ValueTask DisposeAsync() + { + _eventSource?.Dispose(); + await _sourceStream.DisposeAsync(); + await _destinationStream.DisposeAsync(); + await base.DisposeAsync(); + } + } +} diff --git a/src/Microsoft.Diagnostics.Monitoring.WebApi/LoggingExtensions.cs b/src/Microsoft.Diagnostics.Monitoring.WebApi/LoggingExtensions.cs index 81ee488d273..7bd8da75bf9 100644 --- a/src/Microsoft.Diagnostics.Monitoring.WebApi/LoggingExtensions.cs +++ b/src/Microsoft.Diagnostics.Monitoring.WebApi/LoggingExtensions.cs @@ -2,6 +2,7 @@ // 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 Microsoft.Extensions.Logging; using System; @@ -51,6 +52,18 @@ internal static class LoggingExtensions logLevel: LogLevel.Warning, formatString: Strings.LogFormatString_DefaultProcessUnexpectedFailure); + private static readonly Action _stoppingTraceEventHit = + LoggerMessage.Define( + eventId: new EventId(8, "StoppingTraceEventHit"), + logLevel: LogLevel.Debug, + formatString: Strings.LogFormatString_StoppingTraceEventHit); + + private static readonly Action _stoppingTraceEventPayloadFilterMismatch = + LoggerMessage.Define( + eventId: new EventId(9, "StoppingTraceEventPayloadFilterMismatch"), + logLevel: LogLevel.Warning, + formatString: Strings.LogFormatString_StoppingTraceEventPayloadFilterMismatch); + public static void RequestFailed(this ILogger logger, Exception ex) { _requestFailed(logger, ex); @@ -85,5 +98,15 @@ public static void DefaultProcessUnexpectedFailure(this ILogger logger, Exceptio { _defaultProcessUnexpectedFailure(logger, ex); } + + public static void StoppingTraceEventHit(this ILogger logger, TraceEvent traceEvent) + { + _stoppingTraceEventHit(logger, traceEvent.ProviderName, traceEvent.EventName, null); + } + + public static void StoppingTraceEventPayloadFilterMismatch(this ILogger logger, TraceEvent traceEvent) + { + _stoppingTraceEventPayloadFilterMismatch(logger, traceEvent.ProviderName, traceEvent.EventName, string.Join(' ', traceEvent.PayloadNames), null); + } } } diff --git a/src/Microsoft.Diagnostics.Monitoring.WebApi/StreamLeaveOpenWrapper.cs b/src/Microsoft.Diagnostics.Monitoring.WebApi/StreamLeaveOpenWrapper.cs new file mode 100644 index 00000000000..635d87bbf2d --- /dev/null +++ b/src/Microsoft.Diagnostics.Monitoring.WebApi/StreamLeaveOpenWrapper.cs @@ -0,0 +1,74 @@ +// 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.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Diagnostics.Monitoring.WebApi +{ + /// + /// Wraps a given stream but leaves it open on Dispose. + /// + public sealed class StreamLeaveOpenWrapper : Stream + { + private readonly Stream _baseStream; + + public StreamLeaveOpenWrapper(Stream baseStream) : base() + { + _baseStream = baseStream; + } + + public override bool CanSeek => _baseStream.CanSeek; + + public override bool CanTimeout => _baseStream.CanTimeout; + + public override bool CanRead => _baseStream.CanRead; + + public override bool CanWrite => _baseStream.CanWrite; + + public override long Length => _baseStream.Length; + + public override long Position { get => _baseStream.Position; set => _baseStream.Position = value; } + + public override int ReadTimeout { get => _baseStream.ReadTimeout; set => _baseStream.ReadTimeout = value; } + + public override int WriteTimeout { get => _baseStream.WriteTimeout; set => _baseStream.WriteTimeout = value; } + + public override long Seek(long offset, SeekOrigin origin) => _baseStream.Seek(offset, origin); + + public override int Read(Span buffer) => _baseStream.Read(buffer); + + public override int Read(byte[] buffer, int offset, int count) => _baseStream.Read(buffer, offset, count); + + public override int ReadByte() => _baseStream.ReadByte(); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _baseStream.ReadAsync(buffer, offset, count, cancellationToken); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => _baseStream.ReadAsync(buffer, cancellationToken); + + public override void Flush() => _baseStream.Flush(); + + public override void SetLength(long value) => _baseStream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => _baseStream.Write(buffer, offset, count); + + public override void Write(ReadOnlySpan buffer) => _baseStream.Write(buffer); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => _baseStream.WriteAsync(buffer, offset, count, cancellationToken); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => _baseStream.WriteAsync(buffer, cancellationToken); + + public override void WriteByte(byte value) => _baseStream.WriteByte(value); + + public override Task FlushAsync(CancellationToken cancellationToken) => _baseStream.FlushAsync(cancellationToken); + + public override void CopyTo(Stream destination, int bufferSize) => _baseStream.CopyTo(destination, bufferSize); + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) => _baseStream.CopyToAsync(destination, bufferSize, cancellationToken); + + public override async ValueTask DisposeAsync() => await base.DisposeAsync(); + } +} diff --git a/src/Microsoft.Diagnostics.Monitoring.WebApi/Strings.Designer.cs b/src/Microsoft.Diagnostics.Monitoring.WebApi/Strings.Designer.cs index 249969e579a..c2344e16bea 100644 --- a/src/Microsoft.Diagnostics.Monitoring.WebApi/Strings.Designer.cs +++ b/src/Microsoft.Diagnostics.Monitoring.WebApi/Strings.Designer.cs @@ -285,6 +285,24 @@ internal static string LogFormatString_ResolvedTargetProcess { } } + /// + /// Looks up a localized string similar to Hit stopping trace event '{providerName}/{eventName}'. + /// + internal static string LogFormatString_StoppingTraceEventHit { + get { + return ResourceManager.GetString("LogFormatString_StoppingTraceEventHit", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to One or more field names specified in the payload filter for event '{providerName}/{eventName}' do not match any of the known field names: '{payloadFieldNames}'. As a result the requested stopping event is unreachable; will continue to collect the trace for the remaining specified duration.. + /// + internal static string LogFormatString_StoppingTraceEventPayloadFilterMismatch { + get { + return ResourceManager.GetString("LogFormatString_StoppingTraceEventPayloadFilterMismatch", resourceCulture); + } + } + /// /// Looks up a localized string similar to Request limit for endpoint reached. Limit: {limit}, oustanding requests: {requests}. /// diff --git a/src/Microsoft.Diagnostics.Monitoring.WebApi/Strings.resx b/src/Microsoft.Diagnostics.Monitoring.WebApi/Strings.resx index b3b630c51df..dd4f32fda9a 100644 --- a/src/Microsoft.Diagnostics.Monitoring.WebApi/Strings.resx +++ b/src/Microsoft.Diagnostics.Monitoring.WebApi/Strings.resx @@ -225,6 +225,21 @@ Resolved target process. Gets the format string that is printed in the 3:ResolvedTargetProcess event. 0 Format Parameters + + + Hit stopping trace event '{providerName}/{eventName}' + Gets the format string that is printed in the 8:StoppingTraceEventHit event. +2 Format Parameter: +1. providerName: The stopping event provider name. +2. eventName: The stopping event name. + + + One or more field names specified in the payload filter for event '{providerName}/{eventName}' do not match any of the known field names: '{payloadFieldNames}'. As a result the requested stopping event is unreachable; will continue to collect the trace for the remaining specified duration. + Gets the format string that is printed in the 9:StoppingTraceEventPayloadFilterMismatch. +3 Format Parameter: +1. providerName: The stopping event provider name. +2. eventName: The stopping event name. +3. payloadFieldNames: The available payload field names. Request limit for endpoint reached. Limit: {limit}, oustanding requests: {requests} diff --git a/src/Microsoft.Diagnostics.Monitoring.WebApi/Utilities/TraceUtilities.cs b/src/Microsoft.Diagnostics.Monitoring.WebApi/Utilities/TraceUtilities.cs index 0a60cf5948a..050b6856186 100644 --- a/src/Microsoft.Diagnostics.Monitoring.WebApi/Utilities/TraceUtilities.cs +++ b/src/Microsoft.Diagnostics.Monitoring.WebApi/Utilities/TraceUtilities.cs @@ -17,6 +17,9 @@ namespace Microsoft.Diagnostics.Monitoring.WebApi { internal static class TraceUtilities { + // Buffer size matches FileStreamResult + private const int DefaultBufferSize = 0x10000; + public static string GenerateTraceFileName(IEndpointInfo endpointInfo) { return FormattableString.Invariant($"{Utilities.GetFileNameTimeStampUtcNow()}_{endpointInfo.ProcessId}.nettrace"); @@ -82,9 +85,8 @@ public static async Task CaptureTraceAsync(TaskCompletionSource startCom { startCompletionSource.TrySetResult(null); } - //Buffer size matches FileStreamResult //CONSIDER Should we allow client to change the buffer size? - await eventStream.CopyToAsync(outputStream, 0x10000, token); + await eventStream.CopyToAsync(outputStream, DefaultBufferSize, token); }; var client = new DiagnosticsClient(endpointInfo.Endpoint); @@ -97,5 +99,50 @@ public static async Task CaptureTraceAsync(TaskCompletionSource startCom await pipeProcessor.RunAsync(token); } + + public static async Task CaptureTraceUntilEventAsync(TaskCompletionSource startCompletionSource, IEndpointInfo endpointInfo, MonitoringSourceConfiguration configuration, TimeSpan timeout, Stream outputStream, string providerName, string eventName, IDictionary payloadFilter, ILogger logger, CancellationToken token) + { + DiagnosticsClient client = new(endpointInfo.Endpoint); + TaskCompletionSource stoppingEventHitSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + using IDisposable registration = token.Register( + () => stoppingEventHitSource.TrySetCanceled(token)); + + await using EventTracePipeline pipeProcessor = new(client, new EventTracePipelineSettings + { + Configuration = configuration, + Duration = timeout, + }, + async (eventStream, token) => + { + startCompletionSource?.TrySetResult(null); + await using EventMonitoringPassthroughStream eventMonitoringStream = new( + providerName, + eventName, + payloadFilter, + onEvent: (traceEvent) => + { + logger.StoppingTraceEventHit(traceEvent); + stoppingEventHitSource.TrySetResult(null); + }, + onPayloadFilterMismatch: logger.StoppingTraceEventPayloadFilterMismatch, + eventStream, + outputStream, + DefaultBufferSize, + callOnEventOnlyOnce: true, + leaveDestinationStreamOpen: true /* We do not have ownership of the outputStream */); + + await eventMonitoringStream.ProcessAsync(token); + }); + + Task pipelineRunTask = pipeProcessor.RunAsync(token); + await Task.WhenAny(pipelineRunTask, stoppingEventHitSource.Task).Unwrap(); + + if (stoppingEventHitSource.Task.IsCompleted) + { + await pipeProcessor.StopAsync(token); + await pipelineRunTask; + } + } } } diff --git a/src/Tests/Microsoft.Diagnostics.Monitoring.ConfigurationSchema/Microsoft.Diagnostics.Monitoring.ConfigurationSchema.csproj b/src/Tests/Microsoft.Diagnostics.Monitoring.ConfigurationSchema/Microsoft.Diagnostics.Monitoring.ConfigurationSchema.csproj index be259cd1cb3..f4e45fe8ec9 100644 --- a/src/Tests/Microsoft.Diagnostics.Monitoring.ConfigurationSchema/Microsoft.Diagnostics.Monitoring.ConfigurationSchema.csproj +++ b/src/Tests/Microsoft.Diagnostics.Monitoring.ConfigurationSchema/Microsoft.Diagnostics.Monitoring.ConfigurationSchema.csproj @@ -27,6 +27,7 @@ + diff --git a/src/Tests/Microsoft.Diagnostics.Monitoring.TestCommon/TestAppScenarios.cs b/src/Tests/Microsoft.Diagnostics.Monitoring.TestCommon/TestAppScenarios.cs index 374ba1cea37..b9cfa7b748f 100644 --- a/src/Tests/Microsoft.Diagnostics.Monitoring.TestCommon/TestAppScenarios.cs +++ b/src/Tests/Microsoft.Diagnostics.Monitoring.TestCommon/TestAppScenarios.cs @@ -2,6 +2,8 @@ // 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; + namespace Microsoft.Diagnostics.Monitoring.TestCommon { public static class TestAppScenarios @@ -81,5 +83,22 @@ public static class Commands public const string StopSpin = nameof(StopSpin); } } + + public static class TraceEvents + { + public const string Name = nameof(TraceEvents); + public const string UniqueEventName = "UniqueEvent"; + public const string EventProviderName = "TestScenario"; + public const string UniqueEventMessage = "FooBar"; + public const string UniqueEventPayloadField = "message"; + + public const TraceEventOpcode UniqueEventOpcode = TraceEventOpcode.Reply; + + public static class Commands + { + public const string EmitUniqueEvent = nameof(EmitUniqueEvent); + public const string ShutdownScenario = nameof(ShutdownScenario); + } + } } } diff --git a/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests/CollectTraceTests.cs b/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests/CollectTraceTests.cs new file mode 100644 index 00000000000..f1cead807a5 --- /dev/null +++ b/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests/CollectTraceTests.cs @@ -0,0 +1,190 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.Diagnostics.Monitoring.TestCommon; +using Microsoft.Diagnostics.Monitoring.TestCommon.Options; +using Microsoft.Diagnostics.Monitoring.TestCommon.Runners; +using Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests.Fixtures; +using Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests.Runners; +using Microsoft.Diagnostics.Monitoring.WebApi; +using Microsoft.Diagnostics.Monitoring.WebApi.Models; +using Microsoft.Diagnostics.Tools.Monitor.CollectionRules.Options.Actions; +using Microsoft.Diagnostics.Tracing; +using Microsoft.Diagnostics.Tracing.Parsers.Clr; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests +{ + [TargetFrameworkMonikerTrait(TargetFrameworkMonikerExtensions.CurrentTargetFrameworkMoniker)] + [Collection(DefaultCollectionFixture.Name)] + public class CollectTraceTests + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ITestOutputHelper _outputHelper; + + public CollectTraceTests(ITestOutputHelper outputHelper, ServiceProviderFixture serviceProviderFixture) + { + _httpClientFactory = serviceProviderFixture.ServiceProvider.GetService(); + _outputHelper = outputHelper; + } + +#if NET5_0_OR_GREATER + [Fact] + public Task StopOnEvent_Succeeds_WithMatchingOpcode() + { + return StopOnEventTestCore(expectStoppingEvent: true); + } + + [Fact] + public Task StopOnEvent_Succeeds_WithMatchingOpcodeAndNoRundown() + { + return StopOnEventTestCore(expectStoppingEvent: true, collectRundown: false); + } + + [Fact] + public Task StopOnEvent_Succeeds_WithMatchingPayload() + { + return StopOnEventTestCore(expectStoppingEvent: true, payloadFilter: new Dictionary() + { + { TestAppScenarios.TraceEvents.UniqueEventPayloadField, TestAppScenarios.TraceEvents.UniqueEventMessage } + }); + } + + [Fact] + public Task StopOnEvent_DoesNotStop_WhenOpcodeDoesNotMatch() + { + return StopOnEventTestCore(expectStoppingEvent: false, opcode: TraceEventOpcode.Resume); + } + + [Fact] + public Task StopOnEvent_DoesNotStop_WhenPayloadFieldNamesMismatch() + { + return StopOnEventTestCore(expectStoppingEvent: false, payloadFilter: new Dictionary() + { + { TestAppScenarios.TraceEvents.UniqueEventPayloadField, TestAppScenarios.TraceEvents.UniqueEventMessage }, + { "foobar", "baz" } + }); + } + + [Fact] + public Task StopOnEvent_DoesNotStop_WhenPayloadFieldValueMismatch() + { + return StopOnEventTestCore(expectStoppingEvent: false, payloadFilter: new Dictionary() + { + { TestAppScenarios.TraceEvents.UniqueEventPayloadField, TestAppScenarios.TraceEvents.UniqueEventMessage.ToUpperInvariant() } + }); + } + + private string ConstructQualifiedEventName(string eventName, TraceEventOpcode opcode) + { + return (opcode == TraceEventOpcode.Info) + ? eventName + : FormattableString.Invariant($"{eventName}/{opcode}"); + } + + private async Task StopOnEventTestCore(bool expectStoppingEvent, TraceEventOpcode opcode = TestAppScenarios.TraceEvents.UniqueEventOpcode, bool collectRundown = true, IDictionary payloadFilter = null, TimeSpan? duration = null) + { + TimeSpan DefaultCollectTraceTimeout = TimeSpan.FromSeconds(10); + const string DefaultRuleName = "FunctionalTestRule"; + const string EgressProvider = "TmpEgressProvider"; + + using TemporaryDirectory tempDirectory = new(_outputHelper); + + Task ruleCompletedTask = null; + + TraceEventFilter traceEventFilter = new() + { + ProviderName = TestAppScenarios.TraceEvents.EventProviderName, + EventName = ConstructQualifiedEventName(TestAppScenarios.TraceEvents.UniqueEventName, opcode), + PayloadFilter = payloadFilter + }; + + await ScenarioRunner.SingleTarget(_outputHelper, + _httpClientFactory, + DiagnosticPortConnectionMode.Listen, + TestAppScenarios.TraceEvents.Name, + appValidate: async (appRunner, apiClient) => + { + await appRunner.SendCommandAsync(TestAppScenarios.TraceEvents.Commands.EmitUniqueEvent); + await ruleCompletedTask; + await appRunner.SendCommandAsync(TestAppScenarios.TraceEvents.Commands.ShutdownScenario); + }, + configureTool: runner => + { + runner.ConfigurationFromEnvironment.AddFileSystemEgress(EgressProvider, tempDirectory.FullName); + runner.ConfigurationFromEnvironment.CreateCollectionRule(DefaultRuleName) + .SetStartupTrigger() + .AddCollectTraceAction( + new EventPipeProvider[] { + new EventPipeProvider() + { + Name = TestAppScenarios.TraceEvents.EventProviderName, + Keywords = "-1" + } + }, + EgressProvider, options => + { + options.Duration = duration ?? DefaultCollectTraceTimeout; + options.StoppingEvent = traceEventFilter; + options.RequestRundown = collectRundown; + }); + + ruleCompletedTask = runner.WaitForCollectionRuleCompleteAsync(DefaultRuleName); + }); + + string[] files = Directory.GetFiles(tempDirectory.FullName, "*.nettrace", SearchOption.TopDirectoryOnly); + string traceFile = Assert.Single(files); + + var (hasStoppingEvent, hasRundown) = await ValidateNettraceFile(traceFile, traceEventFilter); + Assert.Equal(expectStoppingEvent, hasStoppingEvent); + Assert.Equal(collectRundown, hasRundown); + } + + private Task<(bool hasStoppingEvent, bool hasRundown)> ValidateNettraceFile(string filePath, TraceEventFilter eventFilter) + { + return Task.Run(() => + { + using FileStream fs = File.OpenRead(filePath); + using EventPipeEventSource eventSource = new(fs); + + bool didSeeRundownEvents = false; + bool didSeeStoppingEvent = false; + + eventSource.Dynamic.AddCallbackForProviderEvent(eventFilter.ProviderName, eventFilter.EventName, (obj) => + { + if (eventFilter.PayloadFilter != null) + { + foreach (var (fieldName, fieldValue) in eventFilter.PayloadFilter) + { + object payloadValue = obj.PayloadByName(fieldName); + if (!string.Equals(fieldValue, payloadValue?.ToString(), StringComparison.Ordinal)) + { + return; + } + } + } + + didSeeStoppingEvent = true; + }); + + ClrRundownTraceEventParser rundown = new(eventSource); + rundown.RuntimeStart += (data) => + { + didSeeRundownEvents = true; + }; + + eventSource.Process(); + return (didSeeStoppingEvent, didSeeRundownEvents); + }); + } +#endif // NET5_0_OR_GREATER + } +} diff --git a/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests/Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests.csproj b/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests/Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests.csproj index 260862de60f..14778811d62 100644 --- a/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests/Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests.csproj +++ b/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests/Microsoft.Diagnostics.Monitoring.Tool.FunctionalTests.csproj @@ -28,6 +28,7 @@ + diff --git a/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.UnitTests/CollectionRuleOptionsTests.cs b/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.UnitTests/CollectionRuleOptionsTests.cs index 56d2d827087..95d45b0fc64 100644 --- a/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.UnitTests/CollectionRuleOptionsTests.cs +++ b/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.UnitTests/CollectionRuleOptionsTests.cs @@ -1132,6 +1132,111 @@ public Task CollectionRuleOptions_CollectTraceAction_ProviderPropertyValidation( }); } + [Fact] + public Task CollectionRuleOptions_CollectTraceAction_StopOnEvent() + { + const string ExpectedEgressProvider = "TmpEgressProvider"; + const string ExpectedEventProviderName = "Microsoft-Extensions-Logging"; + List ExpectedProviders = new() + { + new() { Name = ExpectedEventProviderName } + }; + + TraceEventFilter expectedStoppingEvent = new() + { + EventName = "CustomEvent", + ProviderName = ExpectedEventProviderName + }; + + return ValidateSuccess( + rootOptions => + { + rootOptions.CreateCollectionRule(DefaultRuleName) + .SetStartupTrigger() + .AddCollectTraceAction(ExpectedProviders, ExpectedEgressProvider, (options) => + { + options.StoppingEvent = expectedStoppingEvent; + }); + rootOptions.AddFileSystemEgress(ExpectedEgressProvider, "/tmp"); + }, + ruleOptions => + { + ruleOptions.VerifyCollectTraceAction(0, ExpectedProviders, ExpectedEgressProvider, expectedStoppingEvent); + }); + } + + [Fact] + public Task CollectionRuleOptions_CollectTraceAction_StopOnEvent_MissingProviderConfig() + { + const string ExpectedEgressProvider = "TmpEgressProvider"; + const string ExpectedMissingEventProviderName = "Non-Existent-Provider"; + + List ExpectedProviders = new() + { + new() { Name = "Microsoft-Extensions-Logging" } + }; + + return ValidateFailure( + rootOptions => + { + rootOptions.CreateCollectionRule(DefaultRuleName) + .SetStartupTrigger() + .AddCollectTraceAction(ExpectedProviders, ExpectedEgressProvider, (options) => + { + options.StoppingEvent = new TraceEventFilter() + { + EventName = "CustomEvent", + ProviderName = ExpectedMissingEventProviderName + }; + }); + rootOptions.AddFileSystemEgress(ExpectedEgressProvider, "/tmp"); + }, + ex => + { + string[] failures = ex.Failures.ToArray(); + Assert.Single(failures); + VerifyMissingStoppingEventProviderMessage( + failures, + 0, + nameof(CollectTraceOptions.StoppingEvent), + ExpectedMissingEventProviderName, + nameof(CollectTraceOptions.Providers)); + }); + } + + [Fact] + public Task CollectionRuleOptions_CollectTraceAction_BothProfileAndStoppingEvent() + { + const string ExpectedEgressProvider = "TmpEgressProvider"; + + return ValidateFailure( + rootOptions => + { + rootOptions.CreateCollectionRule(DefaultRuleName) + .SetStartupTrigger() + .AddCollectTraceAction(TraceProfile.Metrics, ExpectedEgressProvider, options => + { + options.StoppingEvent = new TraceEventFilter() + { + EventName = "CustomEvent", + ProviderName = "CustomProvider" + }; + }); + + rootOptions.AddFileSystemEgress(ExpectedEgressProvider, "/tmp"); + }, + ex => + { + string[] failures = ex.Failures.ToArray(); + Assert.NotEmpty(failures); + VerifyBothCannotBeSpecifiedMessage( + failures, + 0, + nameof(CollectTraceOptions.Profile), + nameof(CollectTraceOptions.StoppingEvent)); + }); + } + [Fact] public Task CollectionRuleOptions_CollectLiveMetricsAction_RoundTrip() { @@ -1751,5 +1856,17 @@ private static void VerifyFeatureDisabled(string[] failures, int index) Assert.Equal(expectedMessage, failures[index]); } + + private static void VerifyMissingStoppingEventProviderMessage(string[] failures, int index, string fieldName, string providerName, string providerFieldName) + { + string message = string.Format( + CultureInfo.InvariantCulture, + Strings.ErrorMessage_MissingStoppingEventProvider, + fieldName, + providerName, + providerFieldName); + + Assert.Equal(message, failures[index]); + } } } diff --git a/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.UnitTests/Options/CollectionRuleOptionsExtensions.cs b/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.UnitTests/Options/CollectionRuleOptionsExtensions.cs index 7026b21cb62..6574caa9bb7 100644 --- a/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.UnitTests/Options/CollectionRuleOptionsExtensions.cs +++ b/src/Tests/Microsoft.Diagnostics.Monitoring.Tool.UnitTests/Options/CollectionRuleOptionsExtensions.cs @@ -497,7 +497,7 @@ public static CollectTraceOptions VerifyCollectTraceAction(this CollectionRuleOp return collectTraceOptions; } - public static CollectTraceOptions VerifyCollectTraceAction(this CollectionRuleOptions ruleOptions, int actionIndex, IEnumerable providers, string expectedEgress) + public static CollectTraceOptions VerifyCollectTraceAction(this CollectionRuleOptions ruleOptions, int actionIndex, IEnumerable providers, string expectedEgress, TraceEventFilter expectedStoppingEvent = null) { CollectTraceOptions collectTraceOptions = ruleOptions.VerifyAction( actionIndex, KnownCollectionRuleActions.CollectTrace); @@ -505,6 +505,7 @@ public static CollectTraceOptions VerifyCollectTraceAction(this CollectionRuleOp Assert.Equal(expectedEgress, collectTraceOptions.Egress); Assert.NotNull(collectTraceOptions.Providers); Assert.Equal(providers.Count(), collectTraceOptions.Providers.Count); + Assert.Equal(expectedStoppingEvent, collectTraceOptions.StoppingEvent); int index = 0; foreach (EventPipeProvider expectedProvider in providers) diff --git a/src/Tests/Microsoft.Diagnostics.Monitoring.UnitTestApp/Program.cs b/src/Tests/Microsoft.Diagnostics.Monitoring.UnitTestApp/Program.cs index f944d82cb8e..5c2ed74efc8 100644 --- a/src/Tests/Microsoft.Diagnostics.Monitoring.UnitTestApp/Program.cs +++ b/src/Tests/Microsoft.Diagnostics.Monitoring.UnitTestApp/Program.cs @@ -19,7 +19,8 @@ public static Task Main(string[] args) LoggerScenario.Command(), SpinWaitScenario.Command(), EnvironmentVariablesScenario.Command(), - StacksScenario.Command() + StacksScenario.Command(), + TraceEventsScenario.Command() }) .UseDefaults() .Build() diff --git a/src/Tests/Microsoft.Diagnostics.Monitoring.UnitTestApp/Scenarios/TraceEventsScenario.cs b/src/Tests/Microsoft.Diagnostics.Monitoring.UnitTestApp/Scenarios/TraceEventsScenario.cs new file mode 100644 index 00000000000..48d249c863a --- /dev/null +++ b/src/Tests/Microsoft.Diagnostics.Monitoring.UnitTestApp/Scenarios/TraceEventsScenario.cs @@ -0,0 +1,78 @@ +// 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.TestCommon; +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Diagnostics.Tracing; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Diagnostics.Monitoring.UnitTestApp.Scenarios +{ + /// + /// Continously emits trace events and a unique one on request. + /// Only stops once an exit request is received. + /// + internal class TraceEventsScenario + { + [EventSource(Name = "TestScenario")] + class TestScenarioEventSource : EventSource + { + public static TestScenarioEventSource Log { get; } = new TestScenarioEventSource(); + + [Event(1)] + public void RandomNumberGenerated(int number) => WriteEvent(1, number); + + [Event(2, Opcode = EventOpcode.Reply)] + public void UniqueEvent(string message) => WriteEvent(2, message); + } + + public static Command Command() + { + Command command = new(TestAppScenarios.TraceEvents.Name); + command.SetHandler(ExecuteAsync); + return command; + } + + public static async Task ExecuteAsync(InvocationContext context) + { + string[] acceptableCommands = new string[] + { + TestAppScenarios.TraceEvents.Commands.EmitUniqueEvent, + TestAppScenarios.TraceEvents.Commands.ShutdownScenario + }; + + context.ExitCode = await ScenarioHelpers.RunScenarioAsync(async logger => + { + using ManualResetEventSlim stopGeneratingEvents = new(initialState: false); + + Task eventEmitterTask = Task.Run(async () => + { + Random random = new(); + while (!stopGeneratingEvents.IsSet) + { + TestScenarioEventSource.Log.RandomNumberGenerated(random.Next()); + await Task.Delay(TimeSpan.FromMilliseconds(100), context.GetCancellationToken()); + } + }, context.GetCancellationToken()); + + while (true) + { + switch (await ScenarioHelpers.WaitForCommandAsync(acceptableCommands, logger)) + { + case TestAppScenarios.TraceEvents.Commands.EmitUniqueEvent: + TestScenarioEventSource.Log.UniqueEvent(TestAppScenarios.TraceEvents.UniqueEventMessage); + break; + case TestAppScenarios.TraceEvents.Commands.ShutdownScenario: + stopGeneratingEvents.Set(); + eventEmitterTask.Wait(context.GetCancellationToken()); + return 0; + } + } + }, context.GetCancellationToken()); + } + } +} diff --git a/src/Tools/dotnet-monitor/CollectionRules/Actions/CollectTraceAction.cs b/src/Tools/dotnet-monitor/CollectionRules/Actions/CollectTraceAction.cs index c5949086c9f..b3d4dc8dd2e 100644 --- a/src/Tools/dotnet-monitor/CollectionRules/Actions/CollectTraceAction.cs +++ b/src/Tools/dotnet-monitor/CollectionRules/Actions/CollectTraceAction.cs @@ -8,6 +8,7 @@ using Microsoft.Diagnostics.Tools.Monitor.CollectionRules.Exceptions; using Microsoft.Diagnostics.Tools.Monitor.CollectionRules.Options.Actions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; @@ -66,6 +67,8 @@ protected override async Task ExecuteCoreAsync( MonitoringSourceConfiguration configuration; + TraceEventFilter stoppingEvent = null; + if (Options.Profile.HasValue) { TraceProfile profile = Options.Profile.Value; @@ -78,8 +81,9 @@ protected override async Task ExecuteCoreAsync( EventPipeProvider[] optionsProviders = Options.Providers.ToArray(); bool requestRundown = Options.RequestRundown.GetValueOrDefault(CollectTraceOptionsDefaults.RequestRundown); int bufferSizeMegabytes = Options.BufferSizeMegabytes.GetValueOrDefault(CollectTraceOptionsDefaults.BufferSizeMegabytes); - configuration = TraceUtilities.GetTraceConfiguration(optionsProviders, requestRundown, bufferSizeMegabytes); + + stoppingEvent = Options.StoppingEvent; } string fileName = TraceUtilities.GenerateTraceFileName(EndpointInfo); @@ -90,7 +94,18 @@ protected override async Task ExecuteCoreAsync( async (outputStream, token) => { using IDisposable operationRegistration = _operationTrackerService.Register(EndpointInfo); - await TraceUtilities.CaptureTraceAsync(startCompletionSource, EndpointInfo, configuration, duration, outputStream, token); + if (null != stoppingEvent) + { + ILogger logger = _serviceProvider + .GetRequiredService() + .CreateLogger(); + + await TraceUtilities.CaptureTraceUntilEventAsync(startCompletionSource, EndpointInfo, configuration, duration, outputStream, stoppingEvent.ProviderName, stoppingEvent.EventName, stoppingEvent.PayloadFilter, logger, token); + } + else + { + await TraceUtilities.CaptureTraceAsync(startCompletionSource, EndpointInfo, configuration, duration, outputStream, token); + } }, egressProvider, fileName, diff --git a/src/Tools/dotnet-monitor/CollectionRules/Options/Actions/CollectTraceOptions.Validate.cs b/src/Tools/dotnet-monitor/CollectionRules/Options/Actions/CollectTraceOptions.Validate.cs index 81cc26804a8..49463d3e6dc 100644 --- a/src/Tools/dotnet-monitor/CollectionRules/Options/Actions/CollectTraceOptions.Validate.cs +++ b/src/Tools/dotnet-monitor/CollectionRules/Options/Actions/CollectTraceOptions.Validate.cs @@ -27,9 +27,20 @@ IEnumerable IValidatableObject.Validate(ValidationContext vali bool hasProfile = Profile.HasValue; bool hasProviders = null != Providers && Providers.Any(); + bool hasStoppingEvent = null != StoppingEvent; if (hasProfile) { + if (hasStoppingEvent) + { + results.Add(new ValidationResult( + string.Format( + CultureInfo.InvariantCulture, + Strings.ErrorMessage_TwoFieldsCannotBeSpecified, + nameof(Profile), + nameof(StoppingEvent)))); + } + if (hasProviders) { // Both Profile and Providers cannot be specified at the same time, otherwise @@ -75,6 +86,23 @@ IEnumerable IValidatableObject.Validate(ValidationContext vali nameof(Providers)))); } + if (hasStoppingEvent) + { + bool hasMatchingStoppingProvider = hasProviders + && null != Providers.Find(x => string.Equals(x.Name, StoppingEvent.ProviderName, System.StringComparison.Ordinal)); + + if (!hasMatchingStoppingProvider) + { + results.Add(new ValidationResult( + string.Format( + CultureInfo.InvariantCulture, + Strings.ErrorMessage_MissingStoppingEventProvider, + nameof(StoppingEvent), + StoppingEvent.ProviderName, + nameof(Providers)))); + } + } + return results; } } diff --git a/src/Tools/dotnet-monitor/CollectionRules/Options/Actions/CollectTraceOptions.cs b/src/Tools/dotnet-monitor/CollectionRules/Options/Actions/CollectTraceOptions.cs index 72c752bb9a8..51b213a9fd2 100644 --- a/src/Tools/dotnet-monitor/CollectionRules/Options/Actions/CollectTraceOptions.cs +++ b/src/Tools/dotnet-monitor/CollectionRules/Options/Actions/CollectTraceOptions.cs @@ -63,5 +63,10 @@ internal sealed partial record class CollectTraceOptions : BaseRecordOptions, IE [ValidateEgressProvider] #endif public string Egress { get; set; } + + [Display( + ResourceType = typeof(OptionsDisplayStrings), + Description = nameof(OptionsDisplayStrings.DisplayAttributeDescription_CollectTraceOptions_StoppingEvent))] + public TraceEventFilter StoppingEvent { get; set; } } } diff --git a/src/Tools/dotnet-monitor/CollectionRules/Options/Actions/TraceEventFilter.cs b/src/Tools/dotnet-monitor/CollectionRules/Options/Actions/TraceEventFilter.cs new file mode 100644 index 00000000000..357ceda20c7 --- /dev/null +++ b/src/Tools/dotnet-monitor/CollectionRules/Options/Actions/TraceEventFilter.cs @@ -0,0 +1,38 @@ +// 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.WebApi; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; + +namespace Microsoft.Diagnostics.Tools.Monitor.CollectionRules.Options.Actions +{ + /// + /// A trace event filter. + /// + [DebuggerDisplay("TraceEventFilter")] +#if SCHEMAGEN + [NJsonSchema.Annotations.JsonSchemaFlatten] +#endif + internal sealed record class TraceEventFilter : BaseRecordOptions + { + [Display( + ResourceType = typeof(OptionsDisplayStrings), + Description = nameof(OptionsDisplayStrings.DisplayAttributeDescription_TraceEventFilter_ProviderName))] + [Required] + public string ProviderName { get; set; } + + [Display( + ResourceType = typeof(OptionsDisplayStrings), + Description = nameof(OptionsDisplayStrings.DisplayAttributeDescription_TraceEventFilter_EventName))] + [Required] + public string EventName { get; set; } + + [Display( + ResourceType = typeof(OptionsDisplayStrings), + Description = nameof(OptionsDisplayStrings.DisplayAttributeDescription_TraceEventFilter_PayloadFilter))] + public IDictionary PayloadFilter { get; set; } + } +} diff --git a/src/Tools/dotnet-monitor/Strings.Designer.cs b/src/Tools/dotnet-monitor/Strings.Designer.cs index 9d258fe1053..5469c7218a9 100644 --- a/src/Tools/dotnet-monitor/Strings.Designer.cs +++ b/src/Tools/dotnet-monitor/Strings.Designer.cs @@ -303,6 +303,15 @@ internal static string ErrorMessage_MaxConnections { } } + /// + /// Looks up a localized string similar to The {0} field specified the provider '{1}' but it was not found in the {2} field.. + /// + internal static string ErrorMessage_MissingStoppingEventProvider { + get { + return ResourceManager.GetString("ErrorMessage_MissingStoppingEventProvider", resourceCulture); + } + } + /// /// Looks up a localized string similar to The environment block does not contain the '{0}' variable.. /// diff --git a/src/Tools/dotnet-monitor/Strings.resx b/src/Tools/dotnet-monitor/Strings.resx index ea1fb15a3a3..890ff422e1d 100644 --- a/src/Tools/dotnet-monitor/Strings.resx +++ b/src/Tools/dotnet-monitor/Strings.resx @@ -245,6 +245,14 @@ Value must be between 1 and 254, or -1 (to obtain the maximum number allowed by system resources). + + The {0} field specified the provider '{1}' but it was not found in the {2} field. + Gets the format string for CollectTraceOptions type failure due to missing the needed provider for StoppingEventOptions. +3 Format Parameter: +1. stoppingEventField: The field that specified the StoppingEventOptions that contains the failure. +2. missingProviderName: The specified provider that is missing. +3. providersField: the field that the provider should be defined in. + The environment block does not contain the '{0}' variable. Gets the format string for an error when the given environment variable does not exist.