Skip to content

Commit 572cced

Browse files
committed
Publish Meter-based metrics to AppInsights SDK
1 parent 8913ece commit 572cced

File tree

5 files changed

+431
-23
lines changed

5 files changed

+431
-23
lines changed
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
#nullable enable
5+
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Diagnostics.Metrics;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using Microsoft.ApplicationInsights;
12+
using Microsoft.ApplicationInsights.Extensibility;
13+
using Microsoft.ApplicationInsights.Metrics;
14+
using Microsoft.Extensions.Hosting;
15+
using Microsoft.Extensions.Options;
16+
using AIMetric = Microsoft.ApplicationInsights.Metric;
17+
18+
namespace Microsoft.Azure.WebJobs.Script.Diagnostics
19+
{
20+
/// <summary>
21+
/// A meter listener which exports metrics to Application Insights.
22+
/// </summary>
23+
public sealed class ApplicationInsightsMeterListener : ITelemetryModule, IAsyncDisposable
24+
{
25+
private readonly MeterListener _listener;
26+
private readonly ApplicationInsightsMeterOptions _options;
27+
private readonly CancellationTokenSource _shutdown = new();
28+
29+
private Task _exportTask = Task.CompletedTask;
30+
private TelemetryClient _client = null!;
31+
32+
/// <summary>
33+
/// Initializes a new instance of the <see cref="ApplicationInsightsMeterListener"/> class.
34+
/// </summary>
35+
/// <param name="lifetime">The application lifetime.</param>
36+
/// <param name="options">The options.</param>
37+
public ApplicationInsightsMeterListener(
38+
IHostApplicationLifetime lifetime,
39+
IOptions<ApplicationInsightsMeterOptions> options)
40+
{
41+
ArgumentNullException.ThrowIfNull(options);
42+
ArgumentNullException.ThrowIfNull(lifetime);
43+
44+
_shutdown = CancellationTokenSource.CreateLinkedTokenSource(lifetime.ApplicationStopping);
45+
_options = options.Value;
46+
_listener = new()
47+
{
48+
InstrumentPublished = (instrument, listener) =>
49+
{
50+
if (_options.ShouldListenTo(instrument))
51+
{
52+
listener.EnableMeasurementEvents(instrument, this);
53+
}
54+
},
55+
};
56+
57+
// All of the supported instrument value types.
58+
_listener.SetMeasurementEventCallback(CreateCallback<byte>());
59+
_listener.SetMeasurementEventCallback(CreateCallback<short>());
60+
_listener.SetMeasurementEventCallback(CreateCallback<int>());
61+
_listener.SetMeasurementEventCallback(CreateCallback<long>());
62+
_listener.SetMeasurementEventCallback(CreateCallback<float>());
63+
_listener.SetMeasurementEventCallback(CreateCallback<double>());
64+
_listener.SetMeasurementEventCallback(CreateCallback<decimal>());
65+
}
66+
67+
public void Initialize(TelemetryConfiguration configuration)
68+
{
69+
ArgumentNullException.ThrowIfNull(configuration);
70+
_client = new TelemetryClient(configuration);
71+
_exportTask = CollectAsync(_shutdown.Token);
72+
_listener.Start();
73+
}
74+
75+
public async ValueTask DisposeAsync()
76+
{
77+
_listener.Dispose();
78+
79+
await _shutdown.CancelNoThrowAsync();
80+
await _exportTask.ConfigureAwait(false);
81+
await _client.FlushAsync(default).ConfigureAwait(false);
82+
_shutdown.Dispose();
83+
}
84+
85+
private static MeasurementCallback<T> CreateCallback<T>()
86+
where T : struct
87+
{
88+
return (instrument, value, tags, state) =>
89+
{
90+
if (state is not ApplicationInsightsMeterListener listener)
91+
{
92+
return;
93+
}
94+
95+
listener.Publish(instrument, value, tags);
96+
};
97+
}
98+
99+
private async Task CollectAsync(CancellationToken cancellation)
100+
{
101+
while (!cancellation.IsCancellationRequested)
102+
{
103+
try
104+
{
105+
_listener.RecordObservableInstruments();
106+
await Task.Delay(_options.CollectInterval, cancellation);
107+
}
108+
catch (Exception ex) when (!ex.IsFatal())
109+
{
110+
// swallow exceptions
111+
}
112+
}
113+
}
114+
115+
private void Publish<T>(Instrument instrument, T value, ReadOnlySpan<KeyValuePair<string, object?>> tags)
116+
where T : struct
117+
{
118+
if (instrument is null)
119+
{
120+
return;
121+
}
122+
123+
static bool TrackValue(AIMetric metric, double d)
124+
{
125+
metric.TrackValue(d);
126+
return true;
127+
}
128+
129+
MetricIdentifier identifier = GetIdentifier(instrument, tags);
130+
AIMetric metric = _client.GetMetric(identifier);
131+
132+
double d = MetricHelpers.ConvertToDouble(value);
133+
if (tags.Length == 0)
134+
{
135+
metric.TrackValue(d);
136+
return;
137+
}
138+
139+
// All the calls are unrolled to avoid allocations.
140+
_ = tags.Length switch
141+
{
142+
0 => TrackValue(metric, d), // need to massage return type for switch.
143+
1 => metric.TrackValue(
144+
d,
145+
GetValueOrDefault(tags, 0)),
146+
2 => metric.TrackValue(
147+
d,
148+
GetValueOrDefault(tags, 0),
149+
GetValueOrDefault(tags, 1)),
150+
3 => metric.TrackValue(
151+
d,
152+
GetValueOrDefault(tags, 0),
153+
GetValueOrDefault(tags, 1),
154+
GetValueOrDefault(tags, 2)),
155+
4 => metric.TrackValue(
156+
d,
157+
GetValueOrDefault(tags, 0),
158+
GetValueOrDefault(tags, 1),
159+
GetValueOrDefault(tags, 2),
160+
GetValueOrDefault(tags, 3)),
161+
5 => metric.TrackValue(
162+
d,
163+
GetValueOrDefault(tags, 0),
164+
GetValueOrDefault(tags, 1),
165+
GetValueOrDefault(tags, 2),
166+
GetValueOrDefault(tags, 3),
167+
GetValueOrDefault(tags, 4)),
168+
6 => metric.TrackValue(
169+
d,
170+
GetValueOrDefault(tags, 0),
171+
GetValueOrDefault(tags, 1),
172+
GetValueOrDefault(tags, 2),
173+
GetValueOrDefault(tags, 3),
174+
GetValueOrDefault(tags, 4),
175+
GetValueOrDefault(tags, 5)),
176+
7 => metric.TrackValue(
177+
d,
178+
GetValueOrDefault(tags, 0),
179+
GetValueOrDefault(tags, 1),
180+
GetValueOrDefault(tags, 2),
181+
GetValueOrDefault(tags, 3),
182+
GetValueOrDefault(tags, 4),
183+
GetValueOrDefault(tags, 5),
184+
GetValueOrDefault(tags, 6)),
185+
8 => metric.TrackValue(
186+
d,
187+
GetValueOrDefault(tags, 0),
188+
GetValueOrDefault(tags, 1),
189+
GetValueOrDefault(tags, 2),
190+
GetValueOrDefault(tags, 3),
191+
GetValueOrDefault(tags, 4),
192+
GetValueOrDefault(tags, 5),
193+
GetValueOrDefault(tags, 6),
194+
GetValueOrDefault(tags, 7)),
195+
9 => metric.TrackValue(
196+
d,
197+
GetValueOrDefault(tags, 0),
198+
GetValueOrDefault(tags, 1),
199+
GetValueOrDefault(tags, 2),
200+
GetValueOrDefault(tags, 3),
201+
GetValueOrDefault(tags, 4),
202+
GetValueOrDefault(tags, 5),
203+
GetValueOrDefault(tags, 6),
204+
GetValueOrDefault(tags, 7),
205+
GetValueOrDefault(tags, 8)),
206+
_ => metric.TrackValue(
207+
d, /* only track first 10 dimensions */
208+
GetValueOrDefault(tags, 0),
209+
GetValueOrDefault(tags, 1),
210+
GetValueOrDefault(tags, 2),
211+
GetValueOrDefault(tags, 3),
212+
GetValueOrDefault(tags, 4),
213+
GetValueOrDefault(tags, 5),
214+
GetValueOrDefault(tags, 6),
215+
GetValueOrDefault(tags, 7),
216+
GetValueOrDefault(tags, 8),
217+
GetValueOrDefault(tags, 9)),
218+
};
219+
}
220+
221+
private static MetricIdentifier GetIdentifier(
222+
Instrument instrument, ReadOnlySpan<KeyValuePair<string, object?>> tags)
223+
{
224+
// App insights only supports up to 10 dimensions. We also want to avoid any extra allocation here, so we
225+
// use the explicit ctor and not the IList<string> accepting one.
226+
return new MetricIdentifier(
227+
instrument.Meter.Name,
228+
instrument.Name,
229+
tags.Length > 0 ? tags[0].Key : null,
230+
tags.Length > 1 ? tags[1].Key : null,
231+
tags.Length > 2 ? tags[2].Key : null,
232+
tags.Length > 3 ? tags[3].Key : null,
233+
tags.Length > 4 ? tags[4].Key : null,
234+
tags.Length > 5 ? tags[5].Key : null,
235+
tags.Length > 6 ? tags[6].Key : null,
236+
tags.Length > 7 ? tags[7].Key : null,
237+
tags.Length > 8 ? tags[8].Key : null,
238+
tags.Length > 9 ? tags[9].Key : null);
239+
}
240+
241+
private static string GetValueOrDefault(ReadOnlySpan<KeyValuePair<string, object?>> tags, int index)
242+
=> tags.Length > index && tags[index].Value != null
243+
? tags[index].Value?.ToString() ?? string.Empty : string.Empty;
244+
}
245+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
#nullable enable
5+
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Diagnostics.Metrics;
9+
10+
namespace Microsoft.Azure.WebJobs.Script.Diagnostics
11+
{
12+
/// <summary>
13+
/// Options for <see cref="ApplicationInsightsMeterListener"/>.
14+
/// </summary>
15+
public class ApplicationInsightsMeterOptions
16+
{
17+
/// <summary>
18+
/// Gets the set of meter names to listen to.
19+
/// </summary>
20+
public ISet<string> Sources { get; } = new HashSet<string>(StringComparer.Ordinal);
21+
22+
/// <summary>
23+
/// Gets or sets the interval to collect meter values. Default is 30 seconds.
24+
/// </summary>
25+
/// <remarks>
26+
/// This is the interval at which values for metrics will be tracked on the Application Insights SDK. This is
27+
/// NOT the export interval. Application Insights SDK will export tracked values based on its own internal
28+
/// schedule.
29+
/// </remarks>
30+
public TimeSpan CollectInterval { get; set; } = TimeSpan.FromSeconds(30);
31+
32+
/// <summary>
33+
/// Determines if given instrument should be listened to.
34+
/// </summary>
35+
/// <param name="instrument">The instrument.</param>
36+
/// <returns><c>true</c> if should be listened to, <c>false</c> otherwise.</returns>
37+
public bool ShouldListenTo(Instrument instrument)
38+
{
39+
ArgumentNullException.ThrowIfNull(instrument);
40+
41+
// TODO: consider allowing wildcards or regex
42+
// For now, just exact match on meter name
43+
return Sources.Contains(instrument.Meter.Name);
44+
}
45+
}
46+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See License.txt in the project root for license information.
3+
4+
using System;
5+
6+
namespace Microsoft.Azure.WebJobs.Script.Diagnostics
7+
{
8+
/// <summary>
9+
/// Helpers for metrics.
10+
/// </summary>
11+
internal static class MetricHelpers
12+
{
13+
/// <summary>
14+
/// Convert an unknown struct type to a double, avoiding boxing.
15+
/// </summary>
16+
/// <param name="value">The value to convert.</param>
17+
/// <typeparam name="TIn">The input type.</typeparam>
18+
/// <returns>The converted value.</returns>
19+
public static double ConvertToDouble<TIn>(TIn value)
20+
where TIn : struct
21+
{
22+
return value switch
23+
{
24+
byte b => Convert.ToDouble(b),
25+
short s => Convert.ToDouble(s),
26+
int i => Convert.ToDouble(i),
27+
long l => Convert.ToDouble(l),
28+
float f => Convert.ToDouble(f),
29+
double d => d,
30+
decimal d => Convert.ToDouble(d),
31+
_ => throw new ArgumentException($"Unsupported type: {typeof(TIn)}", nameof(value)),
32+
};
33+
}
34+
35+
/// <summary>
36+
/// Convert an unknown struct type to a long, avoiding boxing.
37+
/// </summary>
38+
/// <param name="value">The value to convert.</param>
39+
/// <typeparam name="TIn">The input type.</typeparam>
40+
/// <returns>The converted value.</returns>
41+
public static long ConvertToLong<TIn>(TIn value)
42+
where TIn : struct
43+
{
44+
return value switch
45+
{
46+
byte b => Convert.ToInt64(b),
47+
short s => Convert.ToInt64(s),
48+
int i => Convert.ToInt64(i),
49+
long l => l,
50+
float f => Convert.ToInt64(f),
51+
double d => Convert.ToInt64(d),
52+
decimal d => Convert.ToInt64(d),
53+
_ => throw new ArgumentException($"Unsupported type: {typeof(TIn)}", nameof(value)),
54+
};
55+
}
56+
}
57+
}

0 commit comments

Comments
 (0)