Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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}";
}
}
}
}
164 changes: 164 additions & 0 deletions src/WebJobs.Script/Diagnostics/ApplicationInsightsMetricExporter.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// A meter listener which exports metrics to Application Insights.
/// </summary>
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!;

/// <summary>
/// Initializes a new instance of the <see cref="ApplicationInsightsMetricExporter"/> class.
/// </summary>
/// <param name="options">The options.</param>
public ApplicationInsightsMetricExporter(IOptions<ApplicationInsightsMetricExporterOptions> 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<byte>());
_listener.SetMeasurementEventCallback(CreateCallback<short>());
_listener.SetMeasurementEventCallback(CreateCallback<int>());
_listener.SetMeasurementEventCallback(CreateCallback<long>());
_listener.SetMeasurementEventCallback(CreateCallback<float>());
_listener.SetMeasurementEventCallback(CreateCallback<double>());
_listener.SetMeasurementEventCallback(CreateCallback<decimal>());
}

/// <summary>
/// Initializes this module, starting the meter listener and exporting process.
/// </summary>
/// <param name="configuration">The telemetry configuration.</param>
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;
}
}

/// <inheritdoc />
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();
}

/// <summary>
/// Flushes the internal client.
/// </summary>
/// <remarks>
/// Primarily for testing purposes.
/// </remarks>
internal void Flush() => _client.Flush();

private static MeasurementCallback<T> CreateCallback<T>()
where T : struct, INumber<T>, 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<KeyValuePair<string, object?>> tags)
{
if (instrument is null)
{
return;
}

_client.TrackInstrument(instrument, value, tags);
}
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Options for <see cref="ApplicationInsightsMetricExporter"/>.
/// </summary>
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<string> IgnoredInstruments =
[
HostMetrics.FaasInvokeDuration,
];

/// <summary>
/// Gets the set of meter names to listen to.
/// </summary>
public ISet<string> Meters { get; } = new HashSet<string>(StringComparer.Ordinal);

/// <summary>
/// Gets or sets the interval to collect meter values. Default is 30 seconds.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public TimeSpan CollectInterval { get; set; } = TimeSpan.FromSeconds(30);

/// <summary>
/// Determines if given instrument should be listened to.
/// </summary>
/// <param name="instrument">The instrument.</param>
/// <returns><c>true</c> if should be listened to, <c>false</c> otherwise.</returns>
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);
}
}
}
Loading