-
Notifications
You must be signed in to change notification settings - Fork 308
Expand file tree
/
Copy pathAppInsightsProvider.cs
More file actions
349 lines (308 loc) · 13.5 KB
/
Copy pathAppInsightsProvider.cs
File metadata and controls
349 lines (308 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
#if NETCOREAPP
using System.Threading.Channels;
#endif
#if DEBUG
using Microsoft.Testing.Platform;
#endif
using Microsoft.Testing.Platform.Configurations;
using Microsoft.Testing.Platform.Helpers;
using Microsoft.Testing.Platform.Logging;
#if !NETCOREAPP
using Microsoft.Testing.Platform.Messages;
#endif
using Microsoft.Testing.Platform.Services;
using Microsoft.Testing.Platform.Telemetry;
namespace Microsoft.Testing.Extensions.Telemetry;
/// <summary>
/// Allows to log telemetry events via AppInsights.
/// </summary>
internal sealed partial class AppInsightsProvider :
ITelemetryCollector,
#if NETCOREAPP
IAsyncDisposable,
#endif
IDisposable
{
// Note: We're currently using the same environment variable as dotnet CLI.
public static readonly string SessionIdEnvVar = "TESTINGPLATFORM_APPINSIGHTS_SESSIONID";
// Allows us to correlate events produced from the same process.
// Not calling this ProcessId, because it has a different meaning.
private static readonly string CurrentReporterId = Guid.NewGuid().ToString();
private readonly string _currentSessionId;
private readonly bool _isCi;
private readonly IEnvironment _environment;
private readonly ITestApplicationCancellationTokenSource _testApplicationCancellationTokenSource;
private readonly IClock _clock;
private readonly ITelemetryInformation _telemetryInformation;
private readonly ITelemetryClientFactory _telemetryClientFactory;
private readonly bool _isDevelopmentRepository;
private readonly ILogger<AppInsightsProvider> _logger;
private readonly Task _telemetryTask;
private readonly CancellationTokenSource _flushTimeoutOrStop = new();
#if NETCOREAPP
private readonly Channel<(string EventName, IDictionary<string, object> ParamsMap)> _payloads;
#else
private readonly SingleConsumerUnboundedChannel<(string EventName, IDictionary<string, object> ParamsMap)> _payloads;
#endif
#if DEBUG
// Telemetry properties that are allowed to contain unhashed information.
private static readonly HashSet<string> KnownUnhashedProperties =
[
TelemetryProperties.VersionPropertyName,
TelemetryProperties.ReporterIdPropertyName,
TelemetryProperties.SessionId,
TelemetryProperties.HostProperties.TestingPlatformVersionPropertyName,
TelemetryProperties.HostProperties.FrameworkDescriptionPropertyName,
TelemetryProperties.HostProperties.OSDescriptionPropertyName,
TelemetryProperties.HostProperties.RuntimeIdentifierPropertyName,
TelemetryProperties.HostProperties.ApplicationModePropertyName,
TelemetryProperties.HostProperties.ExitCodePropertyName,
TelemetryProperties.HostProperties.ExtensionsPropertyName,
// MSTest session telemetry (see MSTestTelemetryDataCollector). These carry aggregated
// counts, anonymized SHA256 type hashes inside JSON envelopes, and well-known enum/string
// values - none of them contain unhashed user-identifying data.
"mstest.config_source",
"mstest.attribute_usage",
"mstest.custom_test_method_types",
"mstest.custom_test_class_types",
"mstest.assertion_usage",
"mstest.setting.parallelization_scope",
];
#endif
private ITelemetryClient? _client;
private bool _isDisposed;
public AppInsightsProvider(
IEnvironment environment,
ITestApplicationCancellationTokenSource testApplicationCancellationTokenSource,
ITask task,
ILoggerFactory loggerFactory,
IClock clock,
IConfiguration configuration,
ITelemetryInformation telemetryInformation,
ITelemetryClientFactory telemetryClientFactory,
string sessionId)
{
_ = bool.TryParse(configuration[PlatformConfigurationConstants.PlatformTelemetryIsDevelopmentRepository], out _isDevelopmentRepository);
_isCi = new CIEnvironmentDetector(environment).IsCIEnvironment();
_environment = environment;
_currentSessionId = sessionId;
_testApplicationCancellationTokenSource = testApplicationCancellationTokenSource;
_clock = clock;
_telemetryInformation = telemetryInformation;
_telemetryClientFactory = telemetryClientFactory;
#if NETCOREAPP
_payloads = Channel.CreateUnbounded<(string EventName, IDictionary<string, object> ParamsMap)>(new UnboundedChannelOptions
{
// We process only 1 data at a time
SingleReader = true,
// We don't know how many threads will call the Log method
SingleWriter = false,
// We want to unlink the caller from the consumer
AllowSynchronousContinuations = false,
});
#else
_payloads = new SingleConsumerUnboundedChannel<(string EventName, IDictionary<string, object> ParamsMap)>();
#endif
_telemetryTask = task.Run(IngestLoopAsync, _testApplicationCancellationTokenSource.CancellationToken);
_logger = loggerFactory.CreateLogger<AppInsightsProvider>();
}
// Initialize the telemetry client and start ingesting events.
private async Task IngestLoopAsync()
{
if (_testApplicationCancellationTokenSource.CancellationToken.IsCancellationRequested)
{
return;
}
try
{
_client = _telemetryClientFactory.Create(_currentSessionId, _environment.OsVersion);
}
catch (Exception e)
{
_client = null;
await _logger.LogErrorAsync("Failed to initialize telemetry client", e).ConfigureAwait(false);
return;
}
DateTimeOffset? lastLoggedError = null;
_testApplicationCancellationTokenSource.CancellationToken.Register(_flushTimeoutOrStop.Cancel);
try
{
#if NETCOREAPP
while (await _payloads.Reader.WaitToReadAsync(_flushTimeoutOrStop.Token).ConfigureAwait(false))
{
{
(string eventName, IDictionary<string, object> paramsMap) = await _payloads.Reader.ReadAsync().ConfigureAwait(false);
#else
while (await _payloads.WaitToReadAsync(_flushTimeoutOrStop.Token).ConfigureAwait(false))
{
while (_payloads.TryRead(out (string EventName, IDictionary<string, object> ParamsMap) payload))
{
if (_flushTimeoutOrStop.Token.IsCancellationRequested)
{
return;
}
(string eventName, IDictionary<string, object> paramsMap) = payload;
#endif
// Add common properties.
paramsMap.Add(TelemetryProperties.VersionPropertyName, _telemetryInformation.Version);
paramsMap.Add(TelemetryProperties.SessionId, _currentSessionId);
paramsMap.Add(TelemetryProperties.ReporterIdPropertyName, CurrentReporterId);
paramsMap.Add(TelemetryProperties.IsCIPropertyName, _isCi.AsTelemetryBool());
if (_isDevelopmentRepository)
{
paramsMap.Add(TelemetryProperties.HostProperties.IsDevelopmentRepositoryPropertyName, TelemetryProperties.True);
}
var metrics = new Dictionary<string, double>();
var properties = new Dictionary<string, string>();
foreach (KeyValuePair<string, object> pair in paramsMap)
{
switch (pair.Value)
{
// Metrics:
case double value:
metrics.Add(pair.Key, value);
break;
case DateTimeOffset value:
metrics.Add(pair.Key, ToUnixTimeNanoseconds(value));
break;
// Properties:
#if DEBUG
case string value:
AssertHashed(pair.Key, value);
properties.Add(pair.Key, value);
break;
#endif
case bool value:
properties.Add(pair.Key, value.AsTelemetryBool());
break;
default:
properties.Add(pair.Key, pair.Value?.ToString() ?? string.Empty);
break;
}
}
if (_logger.IsEnabled(LogLevel.Trace))
{
StringBuilder builder = new();
builder.AppendLine(CultureInfo.InvariantCulture, $"Send telemetry event: {eventName}");
foreach (KeyValuePair<string, string> kvp in properties)
{
builder.AppendLine(CultureInfo.InvariantCulture, $" {kvp.Key}: {kvp.Value}");
}
foreach (KeyValuePair<string, double> kvp in metrics)
{
builder.AppendLine(CultureInfo.InvariantCulture, $" {kvp.Key}: {kvp.Value.ToString("f", CultureInfo.InvariantCulture)}");
}
await _logger.LogTraceAsync(builder.ToString()).ConfigureAwait(false);
}
try
{
_client.TrackEvent(eventName, properties, metrics);
}
catch (Exception ex)
{
// If we have a lot of issues with the network we could have a lot of logs here.
// We log one error every 3 seconds.
// We could do better back-pressure.
if (_logger.IsEnabled(LogLevel.Error) && (!lastLoggedError.HasValue || (_clock.UtcNow - lastLoggedError.Value).TotalSeconds > 3))
{
await _logger.LogErrorAsync("Error during telemetry report.", ex).ConfigureAwait(false);
lastLoggedError = _clock.UtcNow;
}
}
}
}
}
catch (OperationCanceledException)
{
// This is expected when the test application is shutting down or if flush timeout.
}
}
private static double ToUnixTimeNanoseconds(DateTimeOffset value) =>
// The magic number is DateTimeOffset.UnixEpoch.Ticks in newer TFMs.
// We multiply by 100 because Ticks are 100 ns, and we want to report ns.
(value.UtcTicks - 621355968000000000L) * 100;
#if DEBUG
private static void AssertHashed(string key, string value)
{
if (value is TelemetryProperties.True or TelemetryProperties.False)
{
return;
}
// Full qualification of Regex to avoid adding conditional 'using' on top of the file.
if (value.Length == 64 && GetValidHashPattern().IsMatch(value))
{
return;
}
if (KnownUnhashedProperties.Contains(key))
{
return;
}
RoslynDebug.Assert(false, $"Telemetry entry '{key}' contains an unhashed string value '{value}'. Strings need to be hashed using {nameof(Sha256Hasher)}.{nameof(Sha256Hasher.HashWithNormalizedCasing)}(), or white-listed.");
}
#if NET7_0_OR_GREATER
[GeneratedRegex("[a-f0-9]{64}")]
private static partial Regex GetValidHashPattern();
#else
private static Regex GetValidHashPattern()
=> new("[a-f0-9]{64}");
#endif
#endif
public
#if NETCOREAPP
async
#endif
Task LogEventAsync(string eventName, IDictionary<string, object> paramsMap, CancellationToken cancellationToken)
{
#if NETCOREAPP
await _payloads.Writer.WriteAsync((eventName, paramsMap), cancellationToken).ConfigureAwait(false);
#else
if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled(cancellationToken);
}
_payloads.Write((eventName, paramsMap));
return Task.CompletedTask;
#endif
}
// Adding dispose on graceful shutdown per https://github.com/microsoft/ApplicationInsights-dotnet/issues/1152#issuecomment-518742922
public void Dispose()
{
#if NETCOREAPP
_payloads.Writer.Complete();
#else
_payloads.Complete();
#endif
if (!_isDisposed)
{
int flushForSeconds = 3;
if (!_telemetryTask.Wait(TimeSpan.FromSeconds(flushForSeconds)))
{
_flushTimeoutOrStop.Cancel();
_logger.LogWarning($"Telemetry task didn't flush after '{flushForSeconds}', some payload could be lost");
}
_isDisposed = true;
}
}
#if NETCOREAPP
public async ValueTask DisposeAsync()
{
_payloads.Writer.Complete();
if (!_isDisposed)
{
int flushForSeconds = 3;
try
{
await _telemetryTask.TimeoutAfterAsync(TimeSpan.FromSeconds(flushForSeconds)).ConfigureAwait(false);
}
catch (TimeoutException)
{
await _flushTimeoutOrStop.CancelAsync().ConfigureAwait(false);
await _logger.LogWarningAsync($"Telemetry task didn't flush after '{flushForSeconds}', some payload could be lost").ConfigureAwait(false);
}
_isDisposed = true;
}
}
#endif
}