src/Microsoft.Azure.WebJobs.Logging.ApplicationInsights/ApplicationInsightsLogger.cs (551 lines of code) (raw):

// 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.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; using System.Text; using Microsoft.ApplicationInsights; using Microsoft.ApplicationInsights.Channel; using Microsoft.ApplicationInsights.DataContracts; using Microsoft.ApplicationInsights.Extensibility; using Microsoft.AspNetCore.Http; using Microsoft.Azure.WebJobs.Logging.ApplicationInsights.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; namespace Microsoft.Azure.WebJobs.Logging.ApplicationInsights { internal class ApplicationInsightsLogger : ILogger { private readonly TelemetryClient _telemetryClient; private readonly ApplicationInsightsLoggerOptions _loggerOptions; private readonly string _categoryName; private readonly bool _isUserFunction = false; private static readonly ConcurrentDictionary<string, string> _prefixedProperyNames = new ConcurrentDictionary<string, string>(); private const string DefaultCategoryName = "Default"; private const string DateTimeFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffK"; private const string OperationContext = "MS_OperationContext"; internal const string MetricCountKey = "count"; internal const string MetricMinKey = "min"; internal const string MetricMaxKey = "max"; internal const string MetricStandardDeviationKey = "standarddeviation"; private static readonly HashSet<string> SystemScopeKeys = new HashSet<string> { LogConstants.CategoryNameKey, LogConstants.LogLevelKey, LogConstants.EventIdKey, LogConstants.EventNameKey, LogConstants.OriginalFormatKey, ApplicationInsightsScopeKeys.HttpRequest, ScopeKeys.Event, ScopeKeys.FunctionInvocationId, ScopeKeys.FunctionName, ScopeKeys.HostInstanceId, OperationContext, ScopeKeys.TriggerDetails }; public ApplicationInsightsLogger(TelemetryClient client, string categoryName, ApplicationInsightsLoggerOptions loggerOptions) { _telemetryClient = client; _loggerOptions = loggerOptions; _categoryName = categoryName ?? DefaultCategoryName; _isUserFunction = LogCategories.IsFunctionUserCategory(categoryName); } public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter) { IEnumerable<KeyValuePair<string, object>> stateValues = state as IEnumerable<KeyValuePair<string, object>>; // If we don't have anything here, there's nothing to log. if (stateValues == null && formatter == null && exception == null) { return; } // Initialize stateValues so the rest of the methods don't have to worry about null values. stateValues = stateValues ?? Array.Empty<KeyValuePair<string, object>>(); if (_isUserFunction && eventId.Id == LogConstants.MetricEventId) { // Log a metric from user logs only LogMetric(stateValues, logLevel, eventId); } else if (_categoryName == LogCategories.Results) { // Log a function result LogFunctionResult(stateValues, logLevel, exception, eventId); } else if (_categoryName == LogCategories.Aggregator) { // Log an aggregate record LogFunctionResultAggregate(stateValues, logLevel, eventId); } else { string formattedMessage = formatter?.Invoke(state, exception); if (exception != null) { // Log an exception LogException(logLevel, stateValues, exception, formattedMessage, eventId); } else { // Otherwise, log a trace LogTrace(logLevel, stateValues, formattedMessage, eventId); } } } private void LogMetric(IEnumerable<KeyValuePair<string, object>> values, LogLevel logLevel, EventId eventId) { MetricTelemetry telemetry = new MetricTelemetry(); // Always apply scope first to allow state to override. ApplyScopeProperties(telemetry); // Do not apply state properties if optimization is enabled if (!_loggerOptions.EnableMetricsCustomDimensionOptimization) { // Add known properties like category, logLevel and event ApplyKnownProperties(telemetry.Properties, logLevel, eventId); } foreach (var entry in values) { if (entry.Value == null) { continue; } // Name and Value are not lower-case so check for them explicitly first and move to the // next entry if found. switch (entry.Key) { case LogConstants.NameKey when entry.Value is string name: telemetry.Name = name; continue; case LogConstants.MetricValueKey when entry.Value is double sum: telemetry.Sum = sum; continue; case LogConstants.OriginalFormatKey: continue; default: break; } // Now check for case-insensitive matches switch (entry.Key.ToLowerInvariant()) { case MetricCountKey when entry.Value is int count: telemetry.Count = count; break; case MetricMinKey: telemetry.Min = Convert.ToDouble(entry.Value); break; case MetricMaxKey: telemetry.Max = Convert.ToDouble(entry.Value); break; case MetricStandardDeviationKey: telemetry.StandardDeviation = Convert.ToDouble(entry.Value); break; default: // Otherwise, it's a custom property. ApplyProperty(telemetry, entry, true); break; } } _telemetryClient.TrackMetric(telemetry); } // Applies scope properties; filters most system properties, which are used internally private static void ApplyScopeProperties(ITelemetry telemetry) { var scopeProperties = DictionaryLoggerScope.GetMergedStateDictionaryOrNull(); if (scopeProperties != null) { foreach (var scopeProperty in scopeProperties) { if (scopeProperty.Value != null && !SystemScopeKeys.Contains(scopeProperty.Key, StringComparer.Ordinal)) { ApplyProperty(telemetry.Context.Properties, scopeProperty.Key, scopeProperty.Value, true); } } if (scopeProperties.GetValueOrDefault<string>(ScopeKeys.FunctionInvocationId) is string invocationId) { telemetry.Context.Properties[LogConstants.InvocationIdKey] = invocationId; } } telemetry.Context.Operation.Name = scopeProperties.GetValueOrDefault<string>(ScopeKeys.FunctionName); } private void LogException(LogLevel logLevel, IEnumerable<KeyValuePair<string, object>> values, Exception exception, string formattedMessage, EventId eventId) { ExceptionTelemetry telemetry = new ExceptionTelemetry(exception) { SeverityLevel = GetSeverityLevel(logLevel), Timestamp = DateTimeOffset.UtcNow }; if (!string.IsNullOrEmpty(formattedMessage)) { telemetry.Properties[LogConstants.FormattedMessageKey] = formattedMessage; // Also log a trace if there's a formattedMessage. This ensures that the error is visible // in both App Insights analytics tables. LogTrace(logLevel, values, formattedMessage, eventId); } ApplyScopeAndStateProperties(telemetry.Properties, values, telemetry); ApplyKnownProperties(telemetry.Properties, logLevel, eventId); _telemetryClient.TrackException(telemetry); } private void LogTrace(LogLevel logLevel, IEnumerable<KeyValuePair<string, object>> values, string formattedMessage, EventId eventId) { TraceTelemetry telemetry = new TraceTelemetry(formattedMessage); ApplyScopeAndStateProperties(telemetry.Properties, values, telemetry); ApplyKnownProperties(telemetry.Properties, logLevel, eventId); var severityLevel = GetSeverityLevel(logLevel); if (severityLevel.HasValue) { telemetry.SeverityLevel = severityLevel; } // LogLevel.None maps to null, so we have to handle that specially _telemetryClient.TrackTrace(telemetry); } private static SeverityLevel? GetSeverityLevel(LogLevel logLevel) { switch (logLevel) { case LogLevel.Trace: case LogLevel.Debug: return SeverityLevel.Verbose; case LogLevel.Information: return SeverityLevel.Information; case LogLevel.Warning: return SeverityLevel.Warning; case LogLevel.Error: return SeverityLevel.Error; case LogLevel.Critical: return SeverityLevel.Critical; case LogLevel.None: default: return null; } } // Makes sure these are done in the correct order. If there are duplicate keys, the last State property wins. private static void ApplyScopeAndStateProperties(IDictionary<string, string> properties, IEnumerable<KeyValuePair<string, object>> state, ITelemetry telemetry) { ApplyScopeProperties(telemetry); ApplyProperties(properties, state, true); } internal void ApplyKnownProperties(IDictionary<string, string> properties, LogLevel logLevel, EventId eventId) { properties[LogConstants.CategoryNameKey] = _categoryName; properties[LogConstants.LogLevelKey] = logLevel.ToStringOptimized(); if (eventId.Id != 0) { properties[LogConstants.EventIdKey] = Convert.ToString(eventId.Id); } if (!string.IsNullOrEmpty(eventId.Name)) { properties[LogConstants.EventNameKey] = eventId.Name; } } internal static void ApplyProperty(IDictionary<string, string> properties, string key, object objectValue, bool applyPrefix = false) { // do not apply null values if (objectValue == null) { return; } string stringValue = null; // Format dates if (objectValue is string value) { stringValue = value; } else if (objectValue is DateTime date) { stringValue = date.ToUniversalTime().ToString(DateTimeFormatString); } else if (objectValue is DateTimeOffset dateOffset) { stringValue = dateOffset.UtcDateTime.ToString(DateTimeFormatString); } else { stringValue = objectValue.ToString(); } string prefixedKey = applyPrefix ? _prefixedProperyNames.GetOrAdd(key, k => { return $"{LogConstants.CustomPropertyPrefix}{k}"; }) : key; properties[prefixedKey] = stringValue; } private static void ApplyProperty(ISupportProperties telemetry, KeyValuePair<string, object> value, bool applyPrefix = false) { ApplyProperty(telemetry.Properties, value.Key, value.Value, applyPrefix); } // Inserts properties into the telemetry's properties. Properly formats dates, removes nulls, applies prefix, etc. private static void ApplyProperties(IDictionary<string, string> properties, IEnumerable<KeyValuePair<string, object>> values, bool applyPrefix = false) { foreach (var property in values) { ApplyProperty(properties, property.Key, property.Value, applyPrefix); } } private void LogFunctionResultAggregate(IEnumerable<KeyValuePair<string, object>> values, LogLevel logLevel, EventId eventId) { // Metric names will be created like "{FunctionName} {MetricName}" IDictionary<string, double> metrics = new Dictionary<string, double>(7); string functionName = LoggingConstants.Unknown; // build up the collection of metrics to send foreach (KeyValuePair<string, object> value in values) { switch (value.Key) { case LogConstants.NameKey when value.Value is string name: functionName = name; break; case LogConstants.TimestampKey: case LogConstants.OriginalFormatKey: // Timestamp is created automatically // We won't use the format string here break; default: if (value.Value is int intValue) { metrics.Add(value.Key, Convert.ToDouble(intValue)); } else if (value.Value is double doubleValue) { metrics.Add(value.Key, doubleValue); } else if (value.Value is TimeSpan timeSpan) { // if it's a TimeSpan, log the milliseconds metrics.Add(value.Key, timeSpan.TotalMilliseconds); } // do nothing otherwise break; } } IDictionary<string, string> properties = null; // Do not apply state properties if optimization is enabled if (!_loggerOptions.EnableMetricsCustomDimensionOptimization) { properties = new Dictionary<string, string>(2); ApplyKnownProperties(properties, logLevel, eventId); } foreach (KeyValuePair<string, double> metric in metrics) { _telemetryClient.TrackMetric($"{functionName} {metric.Key}", metric.Value, properties); } } private void LogFunctionResult(IEnumerable<KeyValuePair<string, object>> state, LogLevel logLevel, Exception exception, EventId eventId) { IReadOnlyDictionary<string, object> scopeProps = DictionaryLoggerScope.GetMergedStateDictionaryOrNull(); KeyValuePair<string, object>[] stateProps = state as KeyValuePair<string, object>[] ?? state.ToArray(); // log associated exception details if (exception != null) { LogException(logLevel, stateProps, exception, null, eventId); } ApplyFunctionResultActivityTags(stateProps, scopeProps, logLevel); IOperationHolder<RequestTelemetry> requestOperation = scopeProps?.GetValueOrDefault<IOperationHolder<RequestTelemetry>>(OperationContext); if (requestOperation != null) { // We somehow never started the operation, perhaps, it was auto-tracked by the AI SDK // so there's no way to complete it. RequestTelemetry requestTelemetry = requestOperation.Telemetry; requestTelemetry.Success = exception == null; requestTelemetry.ResponseCode = "0"; // Note: we do not have to set Duration, StartTime, etc. These are handled by the call to Stop() _telemetryClient.StopOperation(requestOperation); } } /// <summary> /// Stamps functions attributes (InvocationId, function execution time, Category and LogLevel) on the Activity.Current /// </summary> /// <param name="state"></param> /// <param name="scope"></param> private void ApplyFunctionResultActivityTags(IEnumerable<KeyValuePair<string, object>> state, IReadOnlyDictionary<string, object> scope, LogLevel logLevel) { // Activity carries tracing context. It is managed by instrumented library (e.g. ServiceBus or Asp.Net Core) // and consumed by ApplicationInsights. // This function stamps all function-related tags on the Activity. Then WebJobsTelemetryInitializer sets them on the RequestTelemetry. // This way, requests reported by WebJobs (e.g. timer trigger) and requests reported by ApplicationInsights (Http, ServiceBus) // both have essential information about function execution Activity currentActivity = Activity.Current; // should always be true if (currentActivity != null) { // Build up the telemetry model. Some values special and go right on the telemetry object. All others // are added to the Properties bag. foreach (KeyValuePair<string, object> prop in state) { switch (prop.Key) { case LogConstants.StartTimeKey: case LogConstants.EndTimeKey: // These values are set by the calls to Start/Stop the telemetry. Other // Loggers may want them, but we'll ignore. break; case LogConstants.LogLevelKey: case LogConstants.CategoryNameKey: case LogConstants.EventIdKey: // this is set in the WebJobs initializer, // we will ignore it here break; case LogConstants.MessageEnqueuedTimeKey: // this is populated when creating telemetry // we will ignore it here break; case LogConstants.DurationKey: if (prop.Value is TimeSpan duration) { currentActivity.AddTag(LogConstants.FunctionExecutionTimeKey, duration.TotalMilliseconds.ToString(CultureInfo.InvariantCulture)); } break; default: // There should be no custom properties here, so just copy // the passed-in values without any 'prop__' prefix. if (prop.Value != null) { currentActivity.AddTag(prop.Key, prop.Value.ToString()); } break; } } currentActivity.AddTag(LogConstants.CategoryNameKey, _categoryName); currentActivity.AddTag(LogConstants.LogLevelKey, logLevel.ToStringOptimized()); if (scope != null) { if (!_loggerOptions.HttpAutoCollectionOptions.EnableHttpTriggerExtendedInfoCollection && scope.TryGetValue(ApplicationInsightsScopeKeys.HttpRequest, out var request) && request is HttpRequest httpRequest) { currentActivity.AddTag(LoggingConstants.ClientIpKey, GetIpAddress(httpRequest)); } } } } public bool IsEnabled(LogLevel logLevel) { // Filtering will occur in the Application Insights pipeline. This allows for the QuickPulse telemetry // to always be sent, even if logging actual records is completely disabled. return true; } public IDisposable BeginScope<TState>(TState state) { if (state == null) { throw new ArgumentNullException(nameof(state)); } StartTelemetryIfFunctionInvocation(state as IDictionary<string, object>); return DictionaryLoggerScope.Push(state); } private void StartTelemetryIfFunctionInvocation(IDictionary<string, object> stateValues) { if (stateValues == null) { return; } var allScopes = DictionaryLoggerScope.GetMergedStateDictionaryOrNull(); // HTTP and ServiceBus triggers are tracked automatically by the ApplicationInsights SDK // In such case a current Activity is present. // We won't track and only stamp function specific details on the RequestTelemetry // created by SDK via Activity when function ends var currentActivity = Activity.Current; if (currentActivity == null || // Activity is tracked, but Functions wants to ignore it: (allScopes != null && allScopes.ContainsKey("MS_IgnoreActivity")) || // Functions create another RequestTrackingTelemetryModule to make sure first request is tracked (as ASP.NET Core starts before web jobs) // however at this point we may discover that RequestTrackingTelemetryModule is disabled by customer and even though Activity exists, request won't be tracked // So, if we've got AspNetCore Activity and EnableHttpTriggerExtendedInfoCollection is false - track request here. (!_loggerOptions.HttpAutoCollectionOptions.EnableHttpTriggerExtendedInfoCollection && IsHttpRequestActivity(currentActivity))) { string functionName = stateValues.GetValueOrDefault<string>(ScopeKeys.FunctionName); string functionInvocationId = stateValues.GetValueOrDefault<string>(ScopeKeys.FunctionInvocationId); string eventName = stateValues.GetValueOrDefault<string>(ScopeKeys.Event); // If we have the invocation id, function name, and event, we know it's a new function. That means // that we want to start a new operation and let App Insights track it for us. if (!string.IsNullOrEmpty(functionName) && !string.IsNullOrEmpty(functionInvocationId) && eventName == LogConstants.FunctionStartEvent) { IOperationHolder<RequestTelemetry> operation; // link represents context from the upstream service that is not necessarily immediate parent // it is used by EventHubs to represent context in the message. // if there is just one link, we'll use it as a parent as an optimization. // if there is more than one, we'll populate them as custom properties IEnumerable<Activity> links = allScopes?.GetValueOrDefault<IEnumerable<Activity>>("Links"); var activities = links as Activity[] ?? links?.ToArray(); if (activities != null) { if (activities.Length == 1) { operation = _telemetryClient.StartOperation<RequestTelemetry>(activities[0]); operation.Telemetry.Name = functionName; } else { operation = CreateRequestFromLinks(activities, functionName); } if (this.TryGetAverageTimeInQueueForBatch(activities, operation.Telemetry.Timestamp, out long enqueuedTime)) { operation.Telemetry.Metrics["timeSinceEnqueued"] = enqueuedTime; } } else { operation = _telemetryClient.StartOperation<RequestTelemetry>(functionName); } var triggerDetails = stateValues.GetValueOrDefault<IDictionary<string, string>>(ScopeKeys.TriggerDetails); if (triggerDetails != null) { triggerDetails.TryGetValue(LogConstants.TriggerDetailsEndpointKey, out var endpoint); triggerDetails.TryGetValue(LogConstants.TriggerDetailsEntityNameKey, out var entity); if (endpoint != null && entity != null) { operation.Telemetry.Source = endpoint.EndsWith("/") ? string.Concat(endpoint, entity) : string.Concat(endpoint, "/", entity); } else if (endpoint != null) { operation.Telemetry.Source = endpoint; } else if (entity != null) { operation.Telemetry.Source = entity; } } // We'll need to store this operation context so we can stop it when the function completes stateValues[OperationContext] = operation; } } // If there is a current activity, it is assumed that Application Insights will track it so we do not start an operation. // However, in some cases (such as Durable functions), this is not the case. This allows the scope to decide whether // an operation should be started, even when the current activity is not null. else if (allScopes != null && allScopes.ContainsKey("MS_TrackActivity")) { var operation = _telemetryClient.StartOperation<RequestTelemetry>(currentActivity); stateValues[OperationContext] = operation; } } private IOperationHolder<RequestTelemetry> CreateRequestFromLinks(Activity[] activities, string functionName) { var operation = _telemetryClient.StartOperation<RequestTelemetry>(functionName); var request = operation.Telemetry; // if there is more than one link (batch dispatch in EventHub trigger) // we'll populate link information on the request telemetry, but don't touch parent for this request PopulateLinks(activities, request); // If any of the links is sampled in (on upstream) we also preliminary sample in // request telemetry and everything that happens in this request scope. // There will be additional level of sampling applied to limit rate to one // configured on this Function/WebJob if (request.ProactiveSamplingDecision == SamplingDecision.SampledIn) { Activity.Current.ActivityTraceFlags |= ActivityTraceFlags.Recorded; } return operation; } private void PopulateLinks(Activity[] activities, RequestTelemetry request) { if (activities.Any(l => l.Recorded)) { request.ProactiveSamplingDecision = SamplingDecision.SampledIn; } var linksJson = new StringBuilder(); linksJson.Append('['); foreach (var link in activities) { var linkTraceId = link.TraceId.ToHexString(); // avoiding json serializers for now for the sake of perf. // serialization is trivial and looks like `_MS.links` property with json blob // [{"operation_Id":"5eca8b153632494ba00f619d6877b134","id":"d4c1279b6e7b7c47"}, // {"operation_Id":"ff28988d0776b44f9ca93352da126047","id":"bf4fa4855d161141"}] linksJson .Append('{') .Append("\"operation_Id\":") .Append('\"') .Append(linkTraceId) .Append('\"') .Append(','); linksJson .Append("\"id\":") .Append('\"') .Append(link.ParentSpanId.ToHexString()) .Append('\"'); // we explicitly ignore sampling flag, tracestate and attributes at this point. linksJson.Append("},"); } if (linksJson.Length > 0) { // remove last comma - trailing commas are not allowed linksJson.Remove(linksJson.Length - 1, 1); } linksJson.Append("]"); request.Properties["_MS.links"] = linksJson.ToString(); } private bool IsHttpRequestActivity(Activity activity) { // Http Activity could be created by ASP.NET Core and then is has OperationName = "Microsoft.AspNetCore.Hosting.HttpRequestIn" // or it could be created by ApplicationInsights in certain scenarios (like W3C support until it is integrated into the ASP.NET Core) // ApplicationInsights Activity is called "ActivityCreatedByHostingDiagnosticListener" // Here we check if activity passed is one of those. return activity != null && (activity.OperationName == "Microsoft.AspNetCore.Hosting.HttpRequestIn" || activity.OperationName == "ActivityCreatedByHostingDiagnosticListener"); } internal static string GetIpAddress(HttpRequest httpRequest) { // first check for X-Forwarded-For; used by load balancers if (httpRequest.Headers?.TryGetValue(ApplicationInsightsScopeKeys.ForwardedForHeaderName, out StringValues headerValues) ?? false) { string ip = headerValues.FirstOrDefault(); if (!string.IsNullOrWhiteSpace(ip)) { return RemovePort(ip); } } return httpRequest.HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? LoggingConstants.ZeroIpAddress; } private static string RemovePort(string address) { // For Web sites in Azure header contains ip address with port e.g. 50.47.87.223:54464 int portSeparatorIndex = address.IndexOf(":", StringComparison.OrdinalIgnoreCase); if (portSeparatorIndex > 0) { return address.Substring(0, portSeparatorIndex); } return address; } private bool TryGetAverageTimeInQueueForBatch(Activity[] links, DateTimeOffset requestStartTime, out long avgTimeInQueue) { avgTimeInQueue = 0; int linksCount = 0; foreach (var link in links) { if (!this.TryGetEnqueuedTime(link, out var msgEnqueuedTime)) { // instrumentation does not consistently report enqueued time, ignoring whole span return false; } avgTimeInQueue += Math.Max(requestStartTime.ToUnixTimeMilliseconds() - msgEnqueuedTime, 0); linksCount++; } if (linksCount == 0) { return false; } avgTimeInQueue /= linksCount; return true; } private bool TryGetEnqueuedTime(Activity link, out long enqueuedTime) { enqueuedTime = 0; foreach (var tag in link.Tags) { if (tag.Key == LogConstants.MessageEnqueuedTimeKey) { return long.TryParse(tag.Value, out enqueuedTime); } } return false; } } }