diff --git a/src/WebJobs.Script/Diagnostics/ApplicationInsightsMetricExporter.EventSource.cs b/src/WebJobs.Script/Diagnostics/ApplicationInsightsMetricExporter.EventSource.cs new file mode 100644 index 0000000000..a8a271831e --- /dev/null +++ b/src/WebJobs.Script/Diagnostics/ApplicationInsightsMetricExporter.EventSource.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Diagnostics.Metrics; +using System.Diagnostics.Tracing; + +namespace Microsoft.Azure.WebJobs.Script.Diagnostics +{ + public sealed partial class ApplicationInsightsMetricExporter + { + [EventSource(Name = $"{ScriptConstants.HostEventSourcePrefix}{nameof(ApplicationInsightsMetricExporter)}")] + private sealed class Events : EventSource + { + public static readonly Events Log = new(); + + private Events() + { + } + + [Event(1, Message = "Failed to collect observable instruments: {0}", Level = EventLevel.Error)] + public void FailedToCollectInstruments(Exception error) => WriteEvent(1, Format(error)); + + [Event(2, Message = "Begin collecting observable instruments.")] + public void BeginCollectObservables() => WriteEvent(2); + + [Event(3, Message = "End collecting observable instruments.")] + public void EndCollectObservables() => WriteEvent(3); + + [Event(4, Message = "Meter listening started.")] + public void MeterListeningStarted() => WriteEvent(4); + + [Event(5, Message = "Meter listening stopped.")] + public void MeterListeningStopped() => WriteEvent(5); + + [Event(6, Message = "Subscribed to instrument {0} on meter {1}.")] + public void SubscribedToInstrument(Instrument instrument) => WriteEvent(6, instrument.Name, instrument.Meter.Name); + + [Event(7, Message = "Error starting metric listener: {0}", Level = EventLevel.Error)] + public void ErrorStartingMetricListener(Exception error) => WriteEvent(7, Format(error)); + + [Event(8, Message = "Flushed TelemetryClient.")] + public void FlushedTelemetryClient() => WriteEvent(8); + + private static string Format(Exception error) + { + return $"{error.GetType().FullName}: {error.Message}"; + } + } + } +} diff --git a/src/WebJobs.Script/Diagnostics/ApplicationInsightsMetricExporter.cs b/src/WebJobs.Script/Diagnostics/ApplicationInsightsMetricExporter.cs new file mode 100644 index 0000000000..4d5e978db1 --- /dev/null +++ b/src/WebJobs.Script/Diagnostics/ApplicationInsightsMetricExporter.cs @@ -0,0 +1,164 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Numerics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.Extensions.Options; + +namespace Microsoft.Azure.WebJobs.Script.Diagnostics +{ + /// + /// A meter listener which exports metrics to Application Insights. + /// + public sealed partial class ApplicationInsightsMetricExporter : ITelemetryModule, IAsyncDisposable + { + private readonly MeterListener _listener; + private readonly ApplicationInsightsMetricExporterOptions _options; + private readonly CancellationTokenSource _shutdown = new(); + + private Task _exportTask = Task.CompletedTask; + private TelemetryClient _client = null!; + + /// + /// Initializes a new instance of the class. + /// + /// The options. + public ApplicationInsightsMetricExporter(IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + _options = options.Value; + _listener = new() + { + InstrumentPublished = (instrument, listener) => + { + if (_options.ShouldListenTo(instrument)) + { + Events.Log.SubscribedToInstrument(instrument); + listener.EnableMeasurementEvents(instrument, this); + } + }, + }; + + // All of the supported instrument value types. + _listener.SetMeasurementEventCallback(CreateCallback()); + _listener.SetMeasurementEventCallback(CreateCallback()); + _listener.SetMeasurementEventCallback(CreateCallback()); + _listener.SetMeasurementEventCallback(CreateCallback()); + _listener.SetMeasurementEventCallback(CreateCallback()); + _listener.SetMeasurementEventCallback(CreateCallback()); + _listener.SetMeasurementEventCallback(CreateCallback()); + } + + /// + /// Initializes this module, starting the meter listener and exporting process. + /// + /// The telemetry configuration. + public void Initialize(TelemetryConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + ObjectDisposedException.ThrowIf(_shutdown.IsCancellationRequested, this); + + try + { + _client = new TelemetryClient(configuration); + _listener.Start(); + _exportTask = CollectAsync(_shutdown.Token); + Events.Log.MeterListeningStarted(); + } + catch (Exception ex) + { + Events.Log.ErrorStartingMetricListener(ex); + throw; + } + } + + /// + public async ValueTask DisposeAsync() + { + Events.Log.MeterListeningStopped(); + + await _shutdown.CancelNoThrowAsync(); + await _exportTask.ConfigureAwait(false); + CollectCore(); // collect one more time to ensure we get the last set of values. + _listener.Dispose(); + + if (_client is { } client) + { + await client.FlushAsync(default).ConfigureAwait(false); + Events.Log.FlushedTelemetryClient(); + } + + _shutdown.Dispose(); + } + + /// + /// Flushes the internal client. + /// + /// + /// Primarily for testing purposes. + /// + internal void Flush() => _client.Flush(); + + private static MeasurementCallback CreateCallback() + where T : struct, INumber, IConvertible + { + return (instrument, value, tags, state) => + { + if (state is not ApplicationInsightsMetricExporter listener) + { + return; + } + + listener.Publish(instrument, value.ToDouble(null), tags); + }; + } + + private async Task CollectAsync(CancellationToken cancellation) + { + while (!cancellation.IsCancellationRequested) + { + try + { + CollectCore(); + await Task.Delay(_options.CollectInterval, cancellation); + } + catch (OperationCanceledException) + { + } + } + } + + private void CollectCore() + { + try + { + Events.Log.BeginCollectObservables(); + _listener.RecordObservableInstruments(); + Events.Log.EndCollectObservables(); + } + catch (Exception ex) when (!ex.IsFatal()) + { + Events.Log.FailedToCollectInstruments(ex); + } + } + + private void Publish(Instrument instrument, double value, ReadOnlySpan> tags) + { + if (instrument is null) + { + return; + } + + _client.TrackInstrument(instrument, value, tags); + } + } +} diff --git a/src/WebJobs.Script/Diagnostics/ApplicationInsightsMetricExporterOptions.cs b/src/WebJobs.Script/Diagnostics/ApplicationInsightsMetricExporterOptions.cs new file mode 100644 index 0000000000..626c842c96 --- /dev/null +++ b/src/WebJobs.Script/Diagnostics/ApplicationInsightsMetricExporterOptions.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using Microsoft.Azure.WebJobs.Script.Metrics; + +namespace Microsoft.Azure.WebJobs.Script.Diagnostics +{ + /// + /// Options for . + /// + public class ApplicationInsightsMetricExporterOptions + { + // AppInsights has some metrics which are already emitted a different way. + // We ignore them here to ensure we don't duplicate the metric. + private static readonly HashSet IgnoredInstruments = + [ + HostMetrics.FaasInvokeDuration, + ]; + + /// + /// Gets the set of meter names to listen to. + /// + public ISet Meters { get; } = new HashSet(StringComparer.Ordinal); + + /// + /// Gets or sets the interval to collect meter values. Default is 30 seconds. + /// + /// + /// This is the interval at which values for metrics will be tracked on the Application Insights SDK. This is + /// NOT the export interval. Application Insights SDK will export tracked values based on its own internal + /// schedule. + /// + public TimeSpan CollectInterval { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// Determines if given instrument should be listened to. + /// + /// The instrument. + /// true if should be listened to, false otherwise. + public bool ShouldListenTo(Instrument instrument) + { + ArgumentNullException.ThrowIfNull(instrument); + + // TODO: consider allowing wildcards or regex + // For now, just exact match on meter name + return Meters.Contains(instrument.Meter.Name) && !IgnoredInstruments.Contains(instrument.Name); + } + } +} \ No newline at end of file diff --git a/src/WebJobs.Script/Diagnostics/TelemetryClientExtensions.cs b/src/WebJobs.Script/Diagnostics/TelemetryClientExtensions.cs new file mode 100644 index 0000000000..168297052e --- /dev/null +++ b/src/WebJobs.Script/Diagnostics/TelemetryClientExtensions.cs @@ -0,0 +1,133 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Numerics; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Metrics; +using AIMetric = Microsoft.ApplicationInsights.Metric; + +namespace Microsoft.Azure.WebJobs.Script.Diagnostics +{ + internal static class TelemetryClientExtensions + { + /// + /// Tracks the as a metric with and + /// (dimensions). + /// + /// The client to track with. + /// The instrument to track a value for. + /// The value of the metric to track. + /// The dimensions of the metric to track. + public static void TrackInstrument( + this TelemetryClient client, + Instrument instrument, + double value, + ReadOnlySpan> tags) + { + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(instrument); + MetricIdentifier identifier = GetIdentifier(instrument, tags, out string[] values); + AIMetric metric = client.GetMetric(identifier); + + if (metric.TryGetDataSeries(out MetricSeries series, true, values)) + { + series.TrackValue(value); + } + } + + private static MetricIdentifier GetIdentifier( + Instrument instrument, + ReadOnlySpan> tags, + out string[] values) + { + // App Insights supports a maximum of 10 dimensions. We will silently drop dimensions beyond that. + int length = Math.Min(tags.Length, 10); + + // We use 10 variables to avoid collection allocations. + string? dimension0 = null, dimension1 = null, dimension2 = null, + dimension3 = null, dimension4 = null, dimension5 = null, + dimension6 = null, dimension7 = null, dimension8 = null, + dimension9 = null; + + // Avoiding array allocations for values as well, at least until we know how many we have. + string? value0 = null, value1 = null, value2 = null, + value3 = null, value4 = null, value5 = null, + value6 = null, value7 = null, value8 = null, + value9 = null; + + int count = 0; // this will be how many 'valid' dimensions we have. + for (int i = 0; i < length; i++) + { + string? value = tags[i].Value?.ToString(); + string name = tags[i].Key; + + // Application Insights does not allow null/empty/whitespace dimension values. + if (!string.IsNullOrWhiteSpace(value)) + { + // We assign to the dimensionN and valueN variables based on the count of valid + // dimensions we've seen so far. + switch (count) + { + case 0: dimension0 = name; value0 = value; break; + case 1: dimension1 = name; value1 = value; break; + case 2: dimension2 = name; value2 = value; break; + case 3: dimension3 = name; value3 = value; break; + case 4: dimension4 = name; value4 = value; break; + case 5: dimension5 = name; value5 = value; break; + case 6: dimension6 = name; value6 = value; break; + case 7: dimension7 = name; value7 = value; break; + case 8: dimension8 = name; value8 = value; break; + case 9: dimension9 = name; value9 = value; break; + } + + count++; + } + } + + if (count == 0) + { + // No dimensions, so return a simple identifier. + values = []; + return new MetricIdentifier(instrument.Meter.Name, instrument.Name); + } + + values = new string[count]; + for (int i = 0; i < count; i++) + { + values[i] = i switch + { + 0 => value0!, + 1 => value1!, + 2 => value2!, + 3 => value3!, + 4 => value4!, + 5 => value5!, + 6 => value6!, + 7 => value7!, + 8 => value8!, + 9 => value9!, + _ => throw new InvalidOperationException(), // will never happen + }; + } + + return new MetricIdentifier( + instrument.Meter.Name, + instrument.Name, + dimension0, + dimension1, + dimension2, + dimension3, + dimension4, + dimension5, + dimension6, + dimension7, + dimension8, + dimension9); + } + } +} diff --git a/src/WebJobs.Script/Extensions/CancellationExtensions.cs b/src/WebJobs.Script/Extensions/CancellationExtensions.cs new file mode 100644 index 0000000000..12f0be03bc --- /dev/null +++ b/src/WebJobs.Script/Extensions/CancellationExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.Script +{ + public static class CancellationExtensions + { + /// + /// Cancels the , ignoring any . + /// + /// The to cancel. + public static void CancelNoThrow(this CancellationTokenSource cts) + { + ArgumentNullException.ThrowIfNull(cts); + + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // Ignore disposed exceptions + } + } + + /// + /// Cancels the , ignoring any . + /// + /// The to cancel. + /// A task that represents the asynchronous cancel operation. + public static async Task CancelNoThrowAsync(this CancellationTokenSource cts) + { + ArgumentNullException.ThrowIfNull(cts); + + try + { + await cts.CancelAsync(); + } + catch (ObjectDisposedException) + { + // Ignore disposed exceptions + } + } + } +} diff --git a/src/WebJobs.Script/ScriptConstants.cs b/src/WebJobs.Script/ScriptConstants.cs index c050973d30..1e0e1443b1 100644 --- a/src/WebJobs.Script/ScriptConstants.cs +++ b/src/WebJobs.Script/ScriptConstants.cs @@ -241,6 +241,8 @@ public static class ScriptConstants public const string HostDiagnosticSourceDebugEventNamePrefix = "debug-"; public const string DiagnosticSourceAssemblyContext = HostDiagnosticSourcePrefix + "AssemblyContext"; + public const string HostEventSourcePrefix = "Azure-Functions-Host-"; + public static readonly ImmutableArray HttpMethods = ImmutableArray.Create("get", "post", "delete", "head", "patch", "put", "options"); public static readonly ImmutableArray AssemblyFileTypes = ImmutableArray.Create(".dll", ".exe"); public static readonly string HostUserAgent = $"azure-functions-host/{ScriptHost.Version}"; diff --git a/src/WebJobs.Script/ScriptHostBuilderExtensions.cs b/src/WebJobs.Script/ScriptHostBuilderExtensions.cs index 851c611a08..70aa6ac4f8 100644 --- a/src/WebJobs.Script/ScriptHostBuilderExtensions.cs +++ b/src/WebJobs.Script/ScriptHostBuilderExtensions.cs @@ -30,6 +30,7 @@ using Microsoft.Azure.WebJobs.Script.Host; using Microsoft.Azure.WebJobs.Script.Http; using Microsoft.Azure.WebJobs.Script.ManagedDependencies; +using Microsoft.Azure.WebJobs.Script.Metrics; using Microsoft.Azure.WebJobs.Script.Scale; using Microsoft.Azure.WebJobs.Script.Workers; using Microsoft.Azure.WebJobs.Script.Workers.Http; @@ -479,40 +480,49 @@ internal static void ConfigureApplicationInsights(this ILoggingBuilder builder, // Initialize ScriptTelemetryInitializer before any other telemetry initializers. // This will allow HostInstanceId to be removed as part of MetricsCustomDimensionOptimization. builder.Services.AddSingleton(); - builder.AddApplicationInsightsWebJobs(o => - { - o.InstrumentationKey = appInsightsInstrumentationKey; - o.ConnectionString = appInsightsConnectionString; - - if (!string.IsNullOrEmpty(eventLogLevel)) + builder.AddApplicationInsightsWebJobs( + o => { - if (Enum.TryParse(eventLogLevel, ignoreCase: true, out EventLevel level)) + o.InstrumentationKey = appInsightsInstrumentationKey; + o.ConnectionString = appInsightsConnectionString; + + if (!string.IsNullOrEmpty(eventLogLevel)) { - o.DiagnosticsEventListenerLogLevel = level; + if (Enum.TryParse(eventLogLevel, ignoreCase: true, out EventLevel level)) + { + o.DiagnosticsEventListenerLogLevel = level; + } + else + { + throw new InvalidEnumArgumentException($"Invalid `{EnvironmentSettingNames.AppInsightsEventListenerLogLevel}`."); + } } - else + if (!string.IsNullOrEmpty(authString)) { - throw new InvalidEnumArgumentException($"Invalid `{EnvironmentSettingNames.AppInsightsEventListenerLogLevel}`."); + o.TokenCredentialOptions = TokenCredentialOptions.ParseAuthenticationString(authString); } - } - if (!string.IsNullOrEmpty(authString)) + }, + t => { - o.TokenCredentialOptions = TokenCredentialOptions.ParseAuthenticationString(authString); - } - }, t => - { - if (t.TelemetryChannel is ServerTelemetryChannel channel) - { - channel.TransmissionStatusEvent += TransmissionStatusHandler.Handler; - } + if (t.TelemetryChannel is ServerTelemetryChannel channel) + { + channel.TransmissionStatusEvent += TransmissionStatusHandler.Handler; + } - t.TelemetryProcessorChainBuilder.Use(next => new WorkerTraceFilterTelemetryProcessor(next)); - t.TelemetryProcessorChainBuilder.Use(next => new ScriptTelemetryProcessor(next)); - }); + t.TelemetryProcessorChainBuilder.Use(next => new WorkerTraceFilterTelemetryProcessor(next)); + t.TelemetryProcessorChainBuilder.Use(next => new ScriptTelemetryProcessor(next)); + }); builder.Services.ConfigureOptions(); builder.Services.AddSingleton(); + // Configure the meter listener, so we publish Meter API based metrics to application insights. + builder.Services.AddSingleton(); + builder.Services.Configure(o => + { + o.Meters.Add(HostMetrics.FaasMeterName); + }); + if (SystemEnvironment.Instance.IsPlaceholderModeEnabled()) { // Disable auto-http and dependency tracking when in placeholder mode. diff --git a/test/WebJobs.Script.Tests/Diagnostics/ApplicationInsightsMetricExporterOptionsTests.cs b/test/WebJobs.Script.Tests/Diagnostics/ApplicationInsightsMetricExporterOptionsTests.cs new file mode 100644 index 0000000000..12c80517bb --- /dev/null +++ b/test/WebJobs.Script.Tests/Diagnostics/ApplicationInsightsMetricExporterOptionsTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +#nullable enable + +using System; +using System.Diagnostics.Metrics; +using AwesomeAssertions; +using Microsoft.Azure.WebJobs.Script.Diagnostics; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Script.Tests.Diagnostics +{ + public class ApplicationInsightsMetricExporterOptionsTests + { + [Fact] + public void ShouldListenTo_NullInstrument_Throws() + { + // arrange + ApplicationInsightsMetricExporterOptions options = new(); + Instrument? instrument = null; + + // act + Action act = () => options.ShouldListenTo(instrument!); + + // assert + act.Should().Throw().WithParameterName("instrument"); + } + + [Fact] + public void ShouldListenTo_Empty_ReturnsFalse() + { + // arrange + ApplicationInsightsMetricExporterOptions options = new(); + using Meter meter = new("test.meter"); + Counter counter = meter.CreateCounter("test.counter"); + + // act + bool result = options.ShouldListenTo(counter); + + // assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldListenTo_MeterNotSet_ReturnsFalse() + { + // arrange + ApplicationInsightsMetricExporterOptions options = new(); + options.Meters.Add("configured.meter"); + using Meter meter = new("different.meter"); + Counter counter = meter.CreateCounter("test.counter"); + + // act + bool result = options.ShouldListenTo(counter); + + // assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldListenTo_MeterSet_ReturnsTrue() + { + // arrange + ApplicationInsightsMetricExporterOptions options = new(); + options.Meters.Add("test.meter"); + using Meter meter = new("test.meter"); + Counter counter = meter.CreateCounter("test.counter"); + + // act + bool result = options.ShouldListenTo(counter); + + // assert + result.Should().BeTrue(); + } + + [Fact] + public void ShouldListenTo_IsCaseSensitive() + { + // arrange + ApplicationInsightsMetricExporterOptions options = new(); + options.Meters.Add("Test.Meter"); + using Meter meter = new("test.meter"); // different case + Counter counter = meter.CreateCounter("test.counter"); + + // act + bool result = options.ShouldListenTo(counter); + + // assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldListenTo_WorksWithDifferentInstrumentTypes() + { + // arrange + ApplicationInsightsMetricExporterOptions options = new(); + options.Meters.Add("test.meter"); + using Meter meter = new("test.meter"); + Counter counter = meter.CreateCounter("test.counter"); + Histogram histogram = meter.CreateHistogram("test.histogram"); + ObservableGauge gauge = meter.CreateObservableGauge("test.gauge", () => 1); + + // act & assert + options.ShouldListenTo(counter).Should().BeTrue(); + options.ShouldListenTo(histogram).Should().BeTrue(); + options.ShouldListenTo(gauge).Should().BeTrue(); + } + + [Fact] + public void ShouldListenTo_HandlesMultipleConfiguredMeters() + { + // arrange + ApplicationInsightsMetricExporterOptions options = new(); + options.Meters.Add("meter1"); + options.Meters.Add("meter2"); + + using Meter meter1 = new("meter1"); + using Meter meter2 = new("meter2"); + using Meter meter3 = new("meter3"); + + Counter counter1 = meter1.CreateCounter("counter1"); + Counter counter2 = meter2.CreateCounter("counter2"); + Counter counter3 = meter3.CreateCounter("counter3"); + + // act & assert + options.ShouldListenTo(counter1).Should().BeTrue(); + options.ShouldListenTo(counter2).Should().BeTrue(); + options.ShouldListenTo(counter3).Should().BeFalse(); + } + } +} \ No newline at end of file diff --git a/test/WebJobs.Script.Tests/Diagnostics/ApplicationInsightsMetricExporterTests.cs b/test/WebJobs.Script.Tests/Diagnostics/ApplicationInsightsMetricExporterTests.cs new file mode 100644 index 0000000000..e89aa4656e --- /dev/null +++ b/test/WebJobs.Script.Tests/Diagnostics/ApplicationInsightsMetricExporterTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Google.Protobuf.WellKnownTypes; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.Azure.WebJobs.Script.Diagnostics; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.Azure.WebJobs.Script.Tests.Diagnostics +{ + public sealed class ApplicationInsightsMetricExporterTests : IDisposable + { + private const string InstrumentName = "test.instrument"; + private readonly TelemetryConfiguration _config; + private readonly List _items = []; + + public ApplicationInsightsMetricExporterTests() + { + Mock mockChannel = new(); + mockChannel.Setup(c => c.Send(It.IsAny())) + .Callback(_items.Add); + + _config = new TelemetryConfiguration + { + ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000", + TelemetryChannel = mockChannel.Object + }; + } + + public static Dictionary> InstrumentActions { get; } = new() + { + ["Counter{byte}"] = (meter) => meter.CreateCounter(InstrumentName).Add(1), + ["Counter{short}"] = (meter) => meter.CreateCounter(InstrumentName).Add(1), + ["Counter{int}"] = (meter) => meter.CreateCounter(InstrumentName).Add(1), + ["Counter{long}"] = (meter) => meter.CreateCounter(InstrumentName).Add(1), + ["Counter{float}"] = (meter) => meter.CreateCounter(InstrumentName).Add(1), + ["Counter{double}"] = (meter) => meter.CreateCounter(InstrumentName).Add(1), + ["Counter{decimal}"] = (meter) => meter.CreateCounter(InstrumentName).Add(1), + ["Histogram{byte}"] = (meter) => meter.CreateHistogram(InstrumentName).Record(1), + ["Histogram{short}"] = (meter) => meter.CreateHistogram(InstrumentName).Record(1), + ["Histogram{int}"] = (meter) => meter.CreateHistogram(InstrumentName).Record(1), + ["Histogram{long}"] = (meter) => meter.CreateHistogram(InstrumentName).Record(1), + ["Histogram{float}"] = (meter) => meter.CreateHistogram(InstrumentName).Record(1), + ["Histogram{double}"] = (meter) => meter.CreateHistogram(InstrumentName).Record(1), + ["Histogram{decimal}"] = (meter) => meter.CreateHistogram(InstrumentName).Record(1), + }; + + public static IEnumerable InstrumentTests => InstrumentActions.Keys.Select(k => new object[] { k }); + + public void Dispose() + { + _config.Dispose(); + } + + [Fact] + public void Constructor_ThrowsOnNullOptions() + { + // act + Action act = () => new ApplicationInsightsMetricExporter(null!); + + // assert + act.Should().Throw().WithParameterName("options"); + } + + [Fact] + public void Initialize_ThrowsOnNullConfiguration() + { + // arrange + ApplicationInsightsMetricExporter exporter = CreateExporter(); + + // act + Action act = () => exporter.Initialize(null!); + + // assert + act.Should().Throw().WithParameterName("configuration"); + } + + [Fact] + public void MeterListener_IgnoresInstrumentsNotInConfiguration() + { + // arrange + ApplicationInsightsMetricExporter exporter = CreateExporter("configured.meter"); + exporter.Initialize(_config); + + // act - create instrument from unconfigured meter + using Meter meter = new("unconfigured.meter"); + Counter counter = meter.CreateCounter("test.counter"); + counter.Add(1); + exporter.Flush(); + + // assert - no telemetry should be sent for unconfigured meters + _items.Should().BeEmpty(); + } + + [Theory] + [MemberData(nameof(InstrumentTests))] + public void MeterListener_TracksConfiguredInstruments(string test) + { + // arrange + ApplicationInsightsMetricExporter exporter = CreateExporter("configured.meter"); + exporter.Initialize(_config); + + // Small delay to ensure initialization completes + Thread.Sleep(100); + + // act - create and use instrument from configured meter + using Meter meter = new("configured.meter"); + InstrumentActions[test](meter); + exporter.Flush(); + + // Small delay to allow async processing + Thread.Sleep(100); + + _items.Should().ContainSingle() + .Which.Should().Satisfy(t => + { + t.Name.Should().Be("test.instrument"); + t.MetricNamespace.Should().Be("configured.meter"); + t.Sum.Should().Be(Convert.ToDouble(1)); + }); + } + + private static ApplicationInsightsMetricExporter CreateExporter(params string[] meters) + => new(CreateOptions(meters)); + + private static OptionsWrapper CreateOptions(params string[] meters) + { + ApplicationInsightsMetricExporterOptions options = new(); + foreach (string meter in meters) + { + options.Meters.Add(meter); + } + + return new(options); + } + } +} diff --git a/test/WebJobs.Script.Tests/Diagnostics/TelemetryClientExtensionsTests.cs b/test/WebJobs.Script.Tests/Diagnostics/TelemetryClientExtensionsTests.cs new file mode 100644 index 0000000000..9d4695ac0d --- /dev/null +++ b/test/WebJobs.Script.Tests/Diagnostics/TelemetryClientExtensionsTests.cs @@ -0,0 +1,225 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Linq; +using AwesomeAssertions; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.ApplicationInsights.Metrics; +using Microsoft.Azure.WebJobs.Script.Diagnostics; +using Moq; +using Xunit; +using AIMetric = Microsoft.ApplicationInsights.Metric; + +#nullable enable + +namespace Microsoft.Azure.WebJobs.Script.Tests.Diagnostics +{ + public sealed class TelemetryClientExtensionsTests : IDisposable + { + private readonly TelemetryClient _client; + private readonly TelemetryConfiguration _config; + private readonly List _items = []; + + public TelemetryClientExtensionsTests() + { + Mock mockChannel = new(); + mockChannel.Setup(c => c.Send(It.IsAny())) + .Callback(_items.Add); + + _config = new() + { + ConnectionString = "InstrumentationKey=00000000-0000-0000-0000-000000000000", + TelemetryChannel = mockChannel.Object, + }; + + _client = new(_config); + } + + public void Dispose() + { + _config.Dispose(); + } + + [Fact] + public void TrackInstrument_NullClient_Throws() + { + // arrange + using Meter meter = new("test.meter.nullclient"); + Counter instrument = meter.CreateCounter("test.metric"); + TelemetryClient client = null!; + + // act + Action act = () => client.TrackInstrument(instrument, 1.0, []); + + // assert + act.Should().Throw().WithParameterName("client"); + } + + [Fact] + public void TrackInstrument_NullInstrument_Throws() + { + // arrange + Instrument instrument = null!; + + // act + Action act = () => _client.TrackInstrument(instrument, 1.0, []); + + // assert + act.Should().Throw().WithParameterName("instrument"); + _items.Should().BeEmpty(); + } + + [Fact] + public void TrackInstrument_NoDimensions_CreatesSeries() + { + // arrange + double value = Random.Shared.NextDouble(); + using Meter meter = new("test.meter.nodims"); + Counter counter = meter.CreateCounter("test.metric.nodims"); + + // act + _client.TrackInstrument(counter, value, []); + _client.Flush(); + + // assert + MetricIdentifier identifier = new(meter.Name, counter.Name); + AIMetric metric = _client.GetMetric(identifier); + bool exists = metric.TryGetDataSeries(out MetricSeries series, false, []); + + metric.SeriesCount.Should().Be(1); + exists.Should().BeTrue(); + series.Should().NotBeNull(); + _items.Should().ContainSingle() + .Which.Should().Satisfy(mt => VerifyMetric(mt, counter, value, null)); + } + + [Fact] + public void TrackInstrument_Dimensions_CreatesSeries() + { + // arrange + double value = Random.Shared.NextDouble(); + using Meter meter = new("test.meter.nodims"); + Counter counter = meter.CreateCounter("test.metric.nodims"); + KeyValuePair[] tags = + [ + new("dim1", "value1"), + new("dim2", "value2") + ]; + + // act + _client.TrackInstrument(counter, value, tags); + _client.Flush(); + + // assert + MetricIdentifier identifier = new(meter.Name, counter.Name, "dim1", "dim2"); + AIMetric metric = _client.GetMetric(identifier); + bool exists = metric.TryGetDataSeries(out MetricSeries series, false, ["value1", "value2"]); + + metric.SeriesCount.Should().Be(2); + exists.Should().BeTrue(); + series.Should().NotBeNull(); + + Dictionary expectedTags = tags.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value?.ToString()!); + _items.Should().ContainSingle() + .Which.Should().Satisfy(mt => VerifyMetric(mt, counter, value, expectedTags)); + } + + [Fact] + public void TrackInstrument_DimensionValues_SkipsInvalid() + { + // arrange + double value = Random.Shared.NextDouble(); + using Meter meter = new("test.meter.skipinvalid"); + Counter counter = meter.CreateCounter("test.metric.skipinvalid"); + KeyValuePair[] tags = + [ + new("valid1", "A"), + new("empty", string.Empty), + new("space", " "), + new("nullval", null), + new("valid2", "B"), + ]; + + // act + _client.TrackInstrument(counter, value, tags); + _client.Flush(); + + // assert (only valid1, valid2 should be present) + MetricIdentifier identifier = new(meter.Name, counter.Name, "valid1", "valid2"); + AIMetric metric = _client.GetMetric(identifier); + bool exists = metric.TryGetDataSeries(out MetricSeries series, false, ["A", "B"]); + + metric.SeriesCount.Should().Be(2); + exists.Should().BeTrue(); + series.Should().NotBeNull(); + + Dictionary expectedTags = new() + { + ["valid1"] = "A", + ["valid2"] = "B", + }; + + _items.Should().ContainSingle() + .Which.Should().Satisfy(mt => VerifyMetric(mt, counter, value, expectedTags)); + } + + [Fact] + public void TrackInstrument_Over10Dimensions_Truncates() + { + // arrange + double value = Random.Shared.NextDouble(); + using Meter meter = new("test.meter.truncate"); + Counter counter = meter.CreateCounter("test.metric.truncate"); + List> tags = []; + for (int i = 0; i < 12; i++) + { + tags.Add(new KeyValuePair("dim" + i, "v" + i)); + } + + // act + _client.TrackInstrument(counter, value, tags.ToArray()); + _client.Flush(); + + // assert (only first 10 dimensions) + MetricIdentifier identifier = new( + meter.Name, + counter.Name, + "dim0", "dim1", "dim2", "dim3", "dim4", "dim5", "dim6", "dim7", "dim8", "dim9"); + AIMetric metric = _client.GetMetric(identifier); + string[] values = ["v0", "v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9"]; + bool exists = metric.TryGetDataSeries(out MetricSeries series, false, values); + + metric.SeriesCount.Should().Be(2); + exists.Should().BeTrue(); + series.Should().NotBeNull(); + + Dictionary expectedTags = tags.Take(10).ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value?.ToString()!); + _items.Should().ContainSingle() + .Which.Should().Satisfy(mt => VerifyMetric(mt, counter, value, expectedTags)); + } + + private static void VerifyMetric( + MetricTelemetry mt, Instrument instrument, double value, Dictionary? tags) + { + tags ??= []; + mt.Should().NotBeNull(); + mt.Name.Should().Be(instrument.Name); + mt.MetricNamespace.Should().Be(instrument.Meter.Name); + mt.Count.Should().Be(1); + mt.Sum.Should().Be(value); + + mt.Properties.Remove("_MS.AggregationIntervalMs"); + mt.Properties.Should().BeEquivalentTo(tags); + } + } +}