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