diff --git a/libraries/Microsoft.Bot.Builder.AI.LUIS/ITelemetryRecognizer.cs b/libraries/Microsoft.Bot.Builder.AI.LUIS/ITelemetryRecognizer.cs new file mode 100644 index 0000000000..967e466fd6 --- /dev/null +++ b/libraries/Microsoft.Bot.Builder.AI.LUIS/ITelemetryRecognizer.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Bot.Builder.AI.Luis +{ + /// + /// Recognizer with Telemetry support. + /// + public interface ITelemetryRecognizer : IRecognizer + { + /// + /// Gets a value indicating whether determines whether to log personal information that came from the user. + /// + /// If true, will log personal information into the IBotTelemetryClient.TrackEvent method; otherwise the properties will be filtered. + bool LogPersonalInformation { get; } + + /// + /// Gets the currently configured that logs the LuisResult event. + /// + /// The being used to log events. + IBotTelemetryClient TelemetryClient { get; } + + /// + /// Return results of the analysis (suggested intents and entities) using the turn context. + /// + /// Context object containing information for a single turn of conversation with a user. + /// Additional properties to be logged to telemetry with the LuisResult event. + /// Additional metrics to be logged to telemetry with the LuisResult event. + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// The LUIS results of the analysis of the current message text in the current turn's context activity. + Task RecognizeAsync(ITurnContext turnContext, Dictionary telemetryProperties, Dictionary telemetryMetrics, CancellationToken cancellationToken = default(CancellationToken)); + + Task RecognizeAsync(ITurnContext turnContext, Dictionary telemetryProperties, Dictionary telemetryMetrics, CancellationToken cancellationToken = default(CancellationToken)) + where T : IRecognizerConvert, new(); + + new Task RecognizeAsync(ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken)) + where T : IRecognizerConvert, new(); + } +} diff --git a/libraries/Microsoft.Bot.Builder.AI.LUIS/LuisRecognizer.cs b/libraries/Microsoft.Bot.Builder.AI.LUIS/LuisRecognizer.cs index 9cc7ccc6fc..5ef2e4bc17 100644 --- a/libraries/Microsoft.Bot.Builder.AI.LUIS/LuisRecognizer.cs +++ b/libraries/Microsoft.Bot.Builder.AI.LUIS/LuisRecognizer.cs @@ -8,7 +8,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Azure.CognitiveServices.Language.LUIS.Runtime; -using Microsoft.Azure.CognitiveServices.Language.LUIS.Runtime.Models; using Microsoft.Bot.Builder.TraceExtensions; using Microsoft.Bot.Configuration; using Microsoft.Bot.Schema; @@ -20,7 +19,7 @@ namespace Microsoft.Bot.Builder.AI.Luis /// /// A LUIS based implementation of . /// - public class LuisRecognizer : IRecognizer + public class LuisRecognizer : IRecognizer, ITelemetryRecognizer { /// /// The value type for a LUIS trace activity. @@ -31,7 +30,7 @@ public class LuisRecognizer : IRecognizer /// The context label for a LUIS trace activity. /// public const string LuisTraceLabel = "Luis Trace"; - private const string _metadataKey = "$instance"; + private readonly ILUISRuntimeClient _runtime; private readonly LuisApplication _application; private readonly LuisPredictionOptions _options; @@ -44,12 +43,16 @@ public class LuisRecognizer : IRecognizer /// (Optional) The LUIS prediction options to use. /// (Optional) TRUE to include raw LUIS API response. /// (Optional) Custom handler for LUIS API calls to allow mocking. - public LuisRecognizer(LuisApplication application, LuisPredictionOptions predictionOptions = null, bool includeApiResults = false, HttpClientHandler clientHandler = null) + /// The IBotTelemetryClient used to log the LuisResult event. + public LuisRecognizer(LuisApplication application, LuisPredictionOptions predictionOptions = null, bool includeApiResults = false, HttpClientHandler clientHandler = null, IBotTelemetryClient telemetryClient = null, bool logPersonalInformation = false) { _application = application ?? throw new ArgumentNullException(nameof(application)); _options = predictionOptions ?? new LuisPredictionOptions(); _includeApiResults = includeApiResults; + TelemetryClient = telemetryClient ?? new NullBotTelemetryClient(); + LogPersonalInformation = logPersonalInformation; + var credentials = new ApiKeyServiceClientCredentials(application.EndpointKey); var delegatingHandler = new LuisDelegatingHandler(); @@ -71,7 +74,7 @@ public LuisRecognizer(LuisApplication application, LuisPredictionOptions predict /// (Optional) TRUE to include raw LUIS API response. /// (Optional) Custom handler for LUIS API calls to allow mocking. public LuisRecognizer(LuisService service, LuisPredictionOptions predictionOptions = null, bool includeApiResults = false, HttpClientHandler clientHandler = null) - : this(new LuisApplication(service), predictionOptions, includeApiResults, clientHandler) + : this(new LuisApplication(service), predictionOptions, includeApiResults, clientHandler, null) { } @@ -83,10 +86,36 @@ public LuisRecognizer(LuisService service, LuisPredictionOptions predictionOptio /// (Optional) TRUE to include raw LUIS API response. /// (Optional) Custom handler for LUIS API calls to allow mocking. public LuisRecognizer(string applicationEndpoint, LuisPredictionOptions predictionOptions = null, bool includeApiResults = false, HttpClientHandler clientHandler = null) - : this(new LuisApplication(applicationEndpoint), predictionOptions, includeApiResults, clientHandler) + : this(new LuisApplication(applicationEndpoint), predictionOptions, includeApiResults, clientHandler, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The IBotTelemetryClient used to log the LuisResult event. + /// The LUIS application to use to recognize text. + /// The LUIS prediction options to use. + /// TRUE to include raw LUIS API response. + /// TRUE to include personally indentifiable information. + public LuisRecognizer(LuisApplication application, LuisPredictionOptions predictionOptions = null, bool includeApiResults = false, IBotTelemetryClient telemetryClient = null, bool logPersonalInformation = false) + : this(application, predictionOptions, includeApiResults, null, telemetryClient) { + LogPersonalInformation = logPersonalInformation; } + /// + /// Gets or sets a value indicating whether to log personal information that came from the user to telemetry. + /// + /// If true, personal information is logged to Telemetry; otherwise the properties will be filtered. + public bool LogPersonalInformation { get; set; } + + /// + /// Gets the currently configured that logs the LuisResult event. + /// + /// The being used to log events. + public IBotTelemetryClient TelemetryClient { get; } + /// /// Returns the name of the top scoring intent from a set of LUIS results. /// @@ -121,300 +150,127 @@ public static string TopIntent(RecognizerResult results, string defaultIntent = /// public async Task RecognizeAsync(ITurnContext turnContext, CancellationToken cancellationToken) - => await RecognizeInternalAsync(turnContext, cancellationToken).ConfigureAwait(false); + => await RecognizeInternalAsync(turnContext, null, null, cancellationToken).ConfigureAwait(false); /// public async Task RecognizeAsync(ITurnContext turnContext, CancellationToken cancellationToken) where T : IRecognizerConvert, new() { var result = new T(); - result.Convert(await RecognizeInternalAsync(turnContext, cancellationToken).ConfigureAwait(false)); + result.Convert(await RecognizeInternalAsync(turnContext, null, null, cancellationToken).ConfigureAwait(false)); return result; } - private static string NormalizedIntent(string intent) => intent.Replace('.', '_').Replace(' ', '_'); - - private static IDictionary GetIntents(LuisResult luisResult) + /// + /// Return results of the analysis (Suggested actions and intents). + /// + /// Context object containing information for a single turn of conversation with a user. + /// Additional properties to be logged to telemetry with the LuisResult event. + /// Additional metrics to be logged to telemetry with the LuisResult event. + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// The LUIS results of the analysis of the current message text in the current turn's context activity. + public async Task RecognizeAsync(ITurnContext turnContext, Dictionary telemetryProperties, Dictionary telemetryMetrics = null, CancellationToken cancellationToken = default(CancellationToken)) { - if (luisResult.Intents != null) - { - return luisResult.Intents.ToDictionary( - i => NormalizedIntent(i.Intent), - i => new IntentScore { Score = i.Score ?? 0 }); - } - else - { - return new Dictionary() - { - { - NormalizedIntent(luisResult.TopScoringIntent.Intent), - new IntentScore() { Score = luisResult.TopScoringIntent.Score ?? 0 } - }, - }; - } + return await RecognizeInternalAsync(turnContext, telemetryProperties, telemetryMetrics, cancellationToken).ConfigureAwait(false); } - private static JObject ExtractEntitiesAndMetadata(IList entities, IList compositeEntities, bool verbose) + /// + /// Return results of the analysis (Suggested actions and intents). + /// + /// Context object containing information for a single turn of conversation with a user. + /// Additional properties to be logged to telemetry with the LuisResult event. + /// Additional metrics to be logged to telemetry with the LuisResult event. + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// The LUIS results of the analysis of the current message text in the current turn's context activity. + public async Task RecognizeAsync(ITurnContext turnContext, Dictionary telemetryProperties, Dictionary telemetryMetrics = null, CancellationToken cancellationToken = default(CancellationToken)) + where T : IRecognizerConvert, new() { - var entitiesAndMetadata = new JObject(); - if (verbose) - { - entitiesAndMetadata[_metadataKey] = new JObject(); - } - - var compositeEntityTypes = new HashSet(); - - // We start by populating composite entities so that entities covered by them are removed from the entities list - if (compositeEntities != null && compositeEntities.Any()) - { - compositeEntityTypes = new HashSet(compositeEntities.Select(ce => ce.ParentType)); - entities = compositeEntities.Aggregate(entities, (current, compositeEntity) => PopulateCompositeEntityModel(compositeEntity, current, entitiesAndMetadata, verbose)); - } - - foreach (var entity in entities) - { - // we'll address composite entities separately - if (compositeEntityTypes.Contains(entity.Type)) - { - continue; - } - - AddProperty(entitiesAndMetadata, ExtractNormalizedEntityName(entity), ExtractEntityValue(entity)); - - if (verbose) - { - AddProperty((JObject)entitiesAndMetadata[_metadataKey], ExtractNormalizedEntityName(entity), ExtractEntityMetadata(entity)); - } - } - - return entitiesAndMetadata; + var result = new T(); + result.Convert(await RecognizeInternalAsync(turnContext, telemetryProperties, telemetryMetrics, cancellationToken).ConfigureAwait(false)); + return result; } - private static JToken Number(dynamic value) + /// + /// Invoked prior to a LuisResult being logged. + /// + /// The Luis Results for the call. + /// Context object containing information for a single turn of conversation with a user. + /// Additional properties to be logged to telemetry with the LuisResult event. + /// Additional metrics to be logged to telemetry with the LuisResult event. + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// . + protected virtual async Task OnRecognizerResultAsync(RecognizerResult recognizerResult, ITurnContext turnContext, Dictionary telemetryProperties = null, Dictionary telemetryMetrics = null, CancellationToken cancellationToken = default(CancellationToken)) { - if (value == null) - { - return null; - } + var properties = await FillLuisEventPropertiesAsync(recognizerResult, turnContext, telemetryProperties, cancellationToken).ConfigureAwait(false); - return long.TryParse((string)value, out var longVal) ? - new JValue(longVal) : - new JValue(double.Parse((string)value)); + // Track the event + TelemetryClient.TrackEvent(LuisTelemetryConstants.LuisResult, properties, telemetryMetrics); + + return; } - private static JToken ExtractEntityValue(EntityModel entity) + /// + /// Fills the event properties for LuisResult event for telemetry. + /// These properties are logged when the recognizer is called. + /// + /// Last activity sent from user. + /// Context object containing information for a single turn of conversation with a user. + /// Additional properties to be logged to telemetry with the LuisResult event. + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// additionalProperties + /// A dictionary that is sent as "Properties" to IBotTelemetryClient.TrackEvent method for the BotMessageSend event. + protected Task> FillLuisEventPropertiesAsync(RecognizerResult recognizerResult, ITurnContext turnContext, Dictionary telemetryProperties = null, CancellationToken cancellationToken = default(CancellationToken)) { -#pragma warning disable IDE0007 // Use implicit type - if (entity.AdditionalProperties == null || !entity.AdditionalProperties.TryGetValue("resolution", out dynamic resolution)) -#pragma warning restore IDE0007 // Use implicit type - { - return entity.Entity; - } + var topLuisIntent = recognizerResult.GetTopScoringIntent(); + var intentScore = topLuisIntent.score.ToString("N2"); + var topTwoIntents = (recognizerResult.Intents.Count > 0) ? recognizerResult.Intents.OrderByDescending(x => x.Value.Score).Take(2).ToArray() : null; - if (entity.Type.StartsWith("builtin.datetime.")) - { - return JObject.FromObject(resolution); - } - else if (entity.Type.StartsWith("builtin.datetimeV2.")) + // Add the intent score and conversation id properties + var properties = new Dictionary() { - if (resolution.values == null || resolution.values.Count == 0) - { - return JArray.FromObject(resolution); - } + { LuisTelemetryConstants.ApplicationIdProperty, _application.ApplicationId }, + { LuisTelemetryConstants.IntentProperty, topTwoIntents?[0].Key ?? string.Empty }, + { LuisTelemetryConstants.IntentScoreProperty, topTwoIntents?[0].Value.Score?.ToString("N2") ?? "0.00" }, + { LuisTelemetryConstants.Intent2Property, (topTwoIntents?.Count() > 1) ? topTwoIntents?[1].Key ?? string.Empty : string.Empty }, + { LuisTelemetryConstants.IntentScore2Property, (topTwoIntents?.Count() > 1) ? topTwoIntents?[1].Value.Score?.ToString("N2") ?? "0.00" : "0.00" }, + { LuisTelemetryConstants.FromIdProperty, turnContext.Activity.From.Id }, - var resolutionValues = (IEnumerable)resolution.values; - var type = resolution.values[0].type; - var timexes = resolutionValues.Select(val => val.timex); - var distinctTimexes = timexes.Distinct(); - return new JObject(new JProperty("type", type), new JProperty("timex", JArray.FromObject(distinctTimexes))); - } - else - { - switch (entity.Type) - { - case "builtin.number": - case "builtin.ordinal": return Number(resolution.value); - case "builtin.percentage": - { - var svalue = (string)resolution.value; - if (svalue.EndsWith("%")) - { - svalue = svalue.Substring(0, svalue.Length - 1); - } - - return Number(svalue); - } - - case "builtin.age": - case "builtin.dimension": - case "builtin.currency": - case "builtin.temperature": - { - var units = (string)resolution.unit; - var val = Number(resolution.value); - var obj = new JObject(); - if (val != null) - { - obj.Add("number", val); - } - - obj.Add("units", units); - return obj; - } - - default: - return resolution.value ?? JArray.FromObject(resolution.values); - } - } - } + }; - private static JObject ExtractEntityMetadata(EntityModel entity) - { - dynamic obj = JObject.FromObject(new + if (recognizerResult.Properties.TryGetValue("sentiment", out var sentiment) && sentiment is JObject) { - startIndex = (int)entity.StartIndex, - endIndex = (int)entity.EndIndex + 1, - text = entity.Entity, - type = entity.Type, - }); - if (entity.AdditionalProperties != null) - { - if (entity.AdditionalProperties.TryGetValue("score", out var score)) + if (((JObject)sentiment).TryGetValue("label", out var label)) { - obj.score = (double)score; + properties.Add(LuisTelemetryConstants.SentimentLabelProperty, label.Value()); } -#pragma warning disable IDE0007 // Use implicit type - if (entity.AdditionalProperties.TryGetValue("resolution", out dynamic resolution) && resolution.subtype != null) -#pragma warning restore IDE0007 // Use implicit type + if (((JObject)sentiment).TryGetValue("score", out var score)) { - obj.subtype = resolution.subtype; + properties.Add(LuisTelemetryConstants.SentimentScoreProperty, score.Value()); } } - return obj; - } - - private static string ExtractNormalizedEntityName(EntityModel entity) - { - // Type::Role -> Role - var type = entity.Type.Split(':').Last(); - if (type.StartsWith("builtin.datetimeV2.")) - { - type = "datetime"; - } - - if (type.StartsWith("builtin.currency")) - { - type = "money"; - } + var entities = recognizerResult.Entities?.ToString(); + properties.Add(LuisTelemetryConstants.EntitiesProperty, entities); - if (type.StartsWith("builtin.")) + // Use the LogPersonalInformation flag to toggle logging PII data, text is a common example + if (LogPersonalInformation && !string.IsNullOrEmpty(turnContext.Activity.Text)) { - type = type.Substring(8); + properties.Add(LuisTelemetryConstants.QuestionProperty, turnContext.Activity.Text); } - var role = entity.AdditionalProperties != null && entity.AdditionalProperties.ContainsKey("role") ? (string)entity.AdditionalProperties["role"] : string.Empty; - if (!string.IsNullOrWhiteSpace(role)) + // Additional Properties can override "stock" properties. + if (telemetryProperties != null) { - type = role; + return Task.FromResult(telemetryProperties.Concat(properties) + .GroupBy(kv => kv.Key) + .ToDictionary(g => g.Key, g => g.First().Value)); } - return type.Replace('.', '_').Replace(' ', '_'); + return Task.FromResult(properties); } - private static IList PopulateCompositeEntityModel(CompositeEntityModel compositeEntity, IList entities, JObject entitiesAndMetadata, bool verbose) - { - var childrenEntites = new JObject(); - var childrenEntitiesMetadata = new JObject(); - if (verbose) - { - childrenEntites[_metadataKey] = new JObject(); - } - - // This is now implemented as O(n^2) search and can be reduced to O(2n) using a map as an optimization if n grows - var compositeEntityMetadata = entities.FirstOrDefault(e => e.Type == compositeEntity.ParentType && e.Entity == compositeEntity.Value); - - // This is an error case and should not happen in theory - if (compositeEntityMetadata == null) - { - return entities; - } - - if (verbose) - { - childrenEntitiesMetadata = ExtractEntityMetadata(compositeEntityMetadata); - childrenEntites[_metadataKey] = new JObject(); - } - - var coveredSet = new HashSet(); - foreach (var child in compositeEntity.Children) - { - foreach (var entity in entities) - { - // We already covered this entity - if (coveredSet.Contains(entity)) - { - continue; - } - - // This entity doesn't belong to this composite entity - if (child.Type != entity.Type || !CompositeContainsEntity(compositeEntityMetadata, entity)) - { - continue; - } - - // Add to the set to ensure that we don't consider the same child entity more than once per composite - coveredSet.Add(entity); - AddProperty(childrenEntites, ExtractNormalizedEntityName(entity), ExtractEntityValue(entity)); - - if (verbose) - { - AddProperty((JObject)childrenEntites[_metadataKey], ExtractNormalizedEntityName(entity), ExtractEntityMetadata(entity)); - } - } - } - - AddProperty(entitiesAndMetadata, ExtractNormalizedEntityName(compositeEntityMetadata), childrenEntites); - if (verbose) - { - AddProperty((JObject)entitiesAndMetadata[_metadataKey], ExtractNormalizedEntityName(compositeEntityMetadata), childrenEntitiesMetadata); - } - - // filter entities that were covered by this composite entity - return entities.Except(coveredSet).ToList(); - } - - private static bool CompositeContainsEntity(EntityModel compositeEntityMetadata, EntityModel entity) - => entity.StartIndex >= compositeEntityMetadata.StartIndex && - entity.EndIndex <= compositeEntityMetadata.EndIndex; - - /// - /// If a property doesn't exist add it to a new array, otherwise append it to the existing array. - /// - private static void AddProperty(JObject obj, string key, JToken value) - { - if (((IDictionary)obj).ContainsKey(key)) - { - ((JArray)obj[key]).Add(value); - } - else - { - obj[key] = new JArray(value); - } - } - - private static void AddProperties(LuisResult luis, RecognizerResult result) - { - if (luis.SentimentAnalysis != null) - { - result.Properties.Add("sentiment", new JObject( - new JProperty("label", luis.SentimentAnalysis.Label), - new JProperty("score", luis.SentimentAnalysis.Score))); - } - } - - private async Task RecognizeInternalAsync(ITurnContext turnContext, CancellationToken cancellationToken) + private async Task RecognizeInternalAsync(ITurnContext turnContext, Dictionary telemetryProperties, Dictionary telemetryMetrics, CancellationToken cancellationToken) { BotAssert.ContextNotNull(turnContext); @@ -445,15 +301,18 @@ private async Task RecognizeInternalAsync(ITurnContext turnCon { Text = utterance, AlteredText = luisResult.AlteredQuery, - Intents = GetIntents(luisResult), - Entities = ExtractEntitiesAndMetadata(luisResult.Entities, luisResult.CompositeEntities, _options.IncludeInstanceData ?? true), + Intents = LuisUtil.GetIntents(luisResult), + Entities = LuisUtil.ExtractEntitiesAndMetadata(luisResult.Entities, luisResult.CompositeEntities, _options.IncludeInstanceData ?? true), }; - AddProperties(luisResult, recognizerResult); + LuisUtil.AddProperties(luisResult, recognizerResult); if (_includeApiResults) { recognizerResult.Properties.Add("luisResult", luisResult); } + // Log telemetry + await OnRecognizerResultAsync(recognizerResult, turnContext, telemetryProperties, telemetryMetrics, cancellationToken).ConfigureAwait(false); + var traceInfo = JObject.FromObject( new { diff --git a/libraries/Microsoft.Bot.Builder.AI.LUIS/LuisTelemetryConstants.cs b/libraries/Microsoft.Bot.Builder.AI.LUIS/LuisTelemetryConstants.cs new file mode 100644 index 0000000000..e878856c56 --- /dev/null +++ b/libraries/Microsoft.Bot.Builder.AI.LUIS/LuisTelemetryConstants.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Bot.Builder.AI.Luis +{ + /// + /// The IBotTelemetryClient event and property names that logged by default. + /// + public static class LuisTelemetryConstants + { + public static readonly string LuisResult = "LuisResult"; // Event name + public static readonly string ApplicationIdProperty = "applicationId"; + public static readonly string IntentProperty = "intent"; + public static readonly string IntentScoreProperty = "intentScore"; + public static readonly string Intent2Property = "intent2"; + public static readonly string IntentScore2Property = "intentScore2"; + public static readonly string EntitiesProperty = "entities"; + public static readonly string QuestionProperty = "question"; + public static readonly string ActivityIdProperty = "activityId"; + public static readonly string SentimentLabelProperty = "sentimentLabel"; + public static readonly string SentimentScoreProperty = "sentimentScore"; + public static readonly string FromIdProperty = "fromId"; + } +} diff --git a/libraries/Microsoft.Bot.Builder.AI.LUIS/LuisUtil.cs b/libraries/Microsoft.Bot.Builder.AI.LUIS/LuisUtil.cs new file mode 100644 index 0000000000..28fa01b883 --- /dev/null +++ b/libraries/Microsoft.Bot.Builder.AI.LUIS/LuisUtil.cs @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Azure.CognitiveServices.Language.LUIS.Runtime.Models; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Bot.Builder.AI.Luis +{ + // Utility functions used to extract and transform data from Luis SDK + internal static class LuisUtil + { + internal const string _metadataKey = "$instance"; + + internal static string NormalizedIntent(string intent) => intent.Replace('.', '_').Replace(' ', '_'); + + internal static IDictionary GetIntents(LuisResult luisResult) + { + if (luisResult.Intents != null) + { + return luisResult.Intents.ToDictionary( + i => NormalizedIntent(i.Intent), + i => new IntentScore { Score = i.Score ?? 0 }); + } + else + { + return new Dictionary() + { + { + NormalizedIntent(luisResult.TopScoringIntent.Intent), + new IntentScore() { Score = luisResult.TopScoringIntent.Score ?? 0 } + }, + }; + } + } + + internal static JObject ExtractEntitiesAndMetadata(IList entities, IList compositeEntities, bool verbose) + { + var entitiesAndMetadata = new JObject(); + if (verbose) + { + entitiesAndMetadata[_metadataKey] = new JObject(); + } + + var compositeEntityTypes = new HashSet(); + + // We start by populating composite entities so that entities covered by them are removed from the entities list + if (compositeEntities != null && compositeEntities.Any()) + { + compositeEntityTypes = new HashSet(compositeEntities.Select(ce => ce.ParentType)); + entities = compositeEntities.Aggregate(entities, (current, compositeEntity) => PopulateCompositeEntityModel(compositeEntity, current, entitiesAndMetadata, verbose)); + } + + foreach (var entity in entities) + { + // we'll address composite entities separately + if (compositeEntityTypes.Contains(entity.Type)) + { + continue; + } + + AddProperty(entitiesAndMetadata, ExtractNormalizedEntityName(entity), ExtractEntityValue(entity)); + + if (verbose) + { + AddProperty((JObject)entitiesAndMetadata[_metadataKey], ExtractNormalizedEntityName(entity), ExtractEntityMetadata(entity)); + } + } + + return entitiesAndMetadata; + } + + internal static JToken Number(dynamic value) + { + if (value == null) + { + return null; + } + + return long.TryParse((string)value, out var longVal) ? + new JValue(longVal) : + new JValue(double.Parse((string)value)); + } + + internal static JToken ExtractEntityValue(EntityModel entity) + { +#pragma warning disable IDE0007 // Use implicit type + if (entity.AdditionalProperties == null || !entity.AdditionalProperties.TryGetValue("resolution", out dynamic resolution)) +#pragma warning restore IDE0007 // Use implicit type + { + return entity.Entity; + } + + if (entity.Type.StartsWith("builtin.datetime.")) + { + return JObject.FromObject(resolution); + } + else if (entity.Type.StartsWith("builtin.datetimeV2.")) + { + if (resolution.values == null || resolution.values.Count == 0) + { + return JArray.FromObject(resolution); + } + + var resolutionValues = (IEnumerable)resolution.values; + var type = resolution.values[0].type; + var timexes = resolutionValues.Select(val => val.timex); + var distinctTimexes = timexes.Distinct(); + return new JObject(new JProperty("type", type), new JProperty("timex", JArray.FromObject(distinctTimexes))); + } + else + { + switch (entity.Type) + { + case "builtin.number": + case "builtin.ordinal": return Number(resolution.value); + case "builtin.percentage": + { + var svalue = (string)resolution.value; + if (svalue.EndsWith("%")) + { + svalue = svalue.Substring(0, svalue.Length - 1); + } + + return Number(svalue); + } + + case "builtin.age": + case "builtin.dimension": + case "builtin.currency": + case "builtin.temperature": + { + var units = (string)resolution.unit; + var val = Number(resolution.value); + var obj = new JObject(); + if (val != null) + { + obj.Add("number", val); + } + + obj.Add("units", units); + return obj; + } + + default: + return resolution.value ?? JArray.FromObject(resolution.values); + } + } + } + + internal static JObject ExtractEntityMetadata(EntityModel entity) + { + dynamic obj = JObject.FromObject(new + { + startIndex = (int)entity.StartIndex, + endIndex = (int)entity.EndIndex + 1, + text = entity.Entity, + type = entity.Type, + }); + if (entity.AdditionalProperties != null) + { + if (entity.AdditionalProperties.TryGetValue("score", out var score)) + { + obj.score = (double)score; + } + +#pragma warning disable IDE0007 // Use implicit type + if (entity.AdditionalProperties.TryGetValue("resolution", out dynamic resolution) && resolution.subtype != null) +#pragma warning restore IDE0007 // Use implicit type + { + obj.subtype = resolution.subtype; + } + } + + return obj; + } + + internal static string ExtractNormalizedEntityName(EntityModel entity) + { + // Type::Role -> Role + var type = entity.Type.Split(':').Last(); + if (type.StartsWith("builtin.datetimeV2.")) + { + type = "datetime"; + } + + if (type.StartsWith("builtin.currency")) + { + type = "money"; + } + + if (type.StartsWith("builtin.")) + { + type = type.Substring(8); + } + + var role = entity.AdditionalProperties != null && entity.AdditionalProperties.ContainsKey("role") ? (string)entity.AdditionalProperties["role"] : string.Empty; + if (!string.IsNullOrWhiteSpace(role)) + { + type = role; + } + + return type.Replace('.', '_').Replace(' ', '_'); + } + + internal static IList PopulateCompositeEntityModel(CompositeEntityModel compositeEntity, IList entities, JObject entitiesAndMetadata, bool verbose) + { + var childrenEntites = new JObject(); + var childrenEntitiesMetadata = new JObject(); + if (verbose) + { + childrenEntites[_metadataKey] = new JObject(); + } + + // This is now implemented as O(n^2) search and can be reduced to O(2n) using a map as an optimization if n grows + var compositeEntityMetadata = entities.FirstOrDefault(e => e.Type == compositeEntity.ParentType && e.Entity == compositeEntity.Value); + + // This is an error case and should not happen in theory + if (compositeEntityMetadata == null) + { + return entities; + } + + if (verbose) + { + childrenEntitiesMetadata = ExtractEntityMetadata(compositeEntityMetadata); + childrenEntites[_metadataKey] = new JObject(); + } + + var coveredSet = new HashSet(); + foreach (var child in compositeEntity.Children) + { + foreach (var entity in entities) + { + // We already covered this entity + if (coveredSet.Contains(entity)) + { + continue; + } + + // This entity doesn't belong to this composite entity + if (child.Type != entity.Type || !CompositeContainsEntity(compositeEntityMetadata, entity)) + { + continue; + } + + // Add to the set to ensure that we don't consider the same child entity more than once per composite + coveredSet.Add(entity); + AddProperty(childrenEntites, ExtractNormalizedEntityName(entity), ExtractEntityValue(entity)); + + if (verbose) + { + AddProperty((JObject)childrenEntites[_metadataKey], ExtractNormalizedEntityName(entity), ExtractEntityMetadata(entity)); + } + } + } + + AddProperty(entitiesAndMetadata, ExtractNormalizedEntityName(compositeEntityMetadata), childrenEntites); + if (verbose) + { + AddProperty((JObject)entitiesAndMetadata[_metadataKey], ExtractNormalizedEntityName(compositeEntityMetadata), childrenEntitiesMetadata); + } + + // filter entities that were covered by this composite entity + return entities.Except(coveredSet).ToList(); + } + + internal static bool CompositeContainsEntity(EntityModel compositeEntityMetadata, EntityModel entity) + => entity.StartIndex >= compositeEntityMetadata.StartIndex && + entity.EndIndex <= compositeEntityMetadata.EndIndex; + + /// + /// If a property doesn't exist add it to a new array, otherwise append it to the existing array. + /// + internal static void AddProperty(JObject obj, string key, JToken value) + { + if (((IDictionary)obj).ContainsKey(key)) + { + ((JArray)obj[key]).Add(value); + } + else + { + obj[key] = new JArray(value); + } + } + + internal static void AddProperties(LuisResult luis, RecognizerResult result) + { + if (luis.SentimentAnalysis != null) + { + result.Properties.Add("sentiment", new JObject( + new JProperty("label", luis.SentimentAnalysis.Label), + new JProperty("score", luis.SentimentAnalysis.Score))); + } + } + } +} diff --git a/libraries/Microsoft.Bot.Builder/TelemetryConstants.cs b/libraries/Microsoft.Bot.Builder/TelemetryConstants.cs new file mode 100644 index 0000000000..5578657bfb --- /dev/null +++ b/libraries/Microsoft.Bot.Builder/TelemetryConstants.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Bot.Builder +{ + public static class TelemetryConstants + { + public const string ChannelIdProperty = "channelId"; + public const string ConversationIdProperty = "conversationId"; + public const string ConversationNameProperty = "conversationName"; + public const string DialogIdProperty = "DialogId"; + public const string FromIdProperty = "fromId"; + public const string FromNameProperty = "fromName"; + public const string LocaleProperty = "locale"; + public const string RecipientIdProperty = "recipientId"; + public const string RecipientNameProperty = "recipientName"; + public const string ReplyActivityIDProperty = "replyActivityId"; + public const string TextProperty = "text"; + public const string SpeakProperty = "speak"; + public const string UserIdProperty = "userId"; + } +} diff --git a/libraries/Microsoft.Bot.Builder/TelemetryLoggerConstants.cs b/libraries/Microsoft.Bot.Builder/TelemetryLoggerConstants.cs new file mode 100644 index 0000000000..4b489fc9b9 --- /dev/null +++ b/libraries/Microsoft.Bot.Builder/TelemetryLoggerConstants.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Bot.Builder +{ + /// + /// The Telemetry Logger Event names. + /// + public static class TelemetryLoggerConstants + { + // The name of the event when when new message is received from the user. + public const string BotMsgReceiveEvent = "BotMessageReceived"; + + // The name of the event when logged when a message is sent from the bot to the user. + public const string BotMsgSendEvent = "BotMessageSend"; + + // The name of the event when a message is updated by the bot. + public const string BotMsgUpdateEvent = "BotMessageUpdate"; + + // The name of the event when a message is deleted by the bot. + public const string BotMsgDeleteEvent = "BotMessageDelete"; + } +} diff --git a/libraries/Microsoft.Bot.Builder/TelemetryLoggerMiddleware.cs b/libraries/Microsoft.Bot.Builder/TelemetryLoggerMiddleware.cs new file mode 100644 index 0000000000..43b04a2409 --- /dev/null +++ b/libraries/Microsoft.Bot.Builder/TelemetryLoggerMiddleware.cs @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Bot.Schema; + +namespace Microsoft.Bot.Builder +{ + /// + /// Middleware for logging incoming, outgoing, updated or deleted Activity messages using IBotTelemetryClient. + /// + public class TelemetryLoggerMiddleware : IMiddleware + { + /// + /// Initializes a new instance of the class. + /// + /// The IBotTelemetryClient implementation used for registering telemetry events. + /// (Optional) TRUE to include personally indentifiable information. + public TelemetryLoggerMiddleware(IBotTelemetryClient telemetryClient, bool logPersonalInformation = false) + { + TelemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + LogPersonalInformation = logPersonalInformation; + } + + /// + /// Gets a value indicating whether indicates whether to log personal information into events. + /// By default, the following properties will not be logged if this is set to false: + /// Activity.From.Name + /// Activity.Text + /// Activity.Speak + /// + /// + /// A value indicating whether indicates whether to log personal information into events. + /// + public bool LogPersonalInformation { get; } + + /// + /// Gets the IBotTelemetryClient. + /// + /// + /// The IBotTelemetryClient. + /// + public IBotTelemetryClient TelemetryClient { get; } + + /// + /// Invoked when a message is received from the user. + /// Performs logging of telemetry data using the IBotTelemetryClient.TrackEvent() method. + /// This event name used is "BotMessageReceived". + /// + /// Current activity sent from user. + /// cancellation token that can be used by other objects + /// or threads to receive notice of cancellation. + /// A task that represents the work queued to execute. + public Task OnReceiveActivityAsync(Activity activity, CancellationToken cancellation) + { + TelemetryClient.TrackEvent(TelemetryLoggerConstants.BotMsgReceiveEvent, FillReceiveEventProperties(activity)); + + return Task.CompletedTask; + } + + /// + /// Invoked when the bot sends a message to the user. + /// Performs logging of telemetry data using the IBotTelemetryClient.TrackEvent() method. + /// This event name used is "BotMessageSend". + /// + /// Current activity sent from user. + /// cancellation token that can be used by other objects + /// or threads to receive notice of cancellation. + /// A task that represents the work queued to execute. + public Task OnSendActivityAsync(Activity activity, CancellationToken cancellation) + { + TelemetryClient.TrackEvent(TelemetryLoggerConstants.BotMsgSendEvent, FillSendEvendProperties(activity)); + + return Task.CompletedTask; + } + + /// + /// Invoked when the bot updates a message. + /// Performs logging of telemetry data using the IBotTelemetryClient.TrackEvent() method. + /// This event name used is "BotMessageUpdate". + /// + /// Current activity sent from user. + /// cancellation token that can be used by other objects + /// or threads to receive notice of cancellation. + /// A task that represents the work queued to execute. + public Task OnUpdateActivityAsync(Activity activity, CancellationToken cancellation) + { + TelemetryClient.TrackEvent(TelemetryLoggerConstants.BotMsgUpdateEvent, FillUpdateEventProperties(activity)); + + return Task.CompletedTask; + + } + + /// + /// Invoked when the bot deletes a message. + /// Performs logging of telemetry data using the IBotTelemetryClient.TrackEvent() method. + /// This event name used is "BotMessageDelete". + /// + /// Current activity sent from user. + /// cancellation token that can be used by other objects + /// or threads to receive notice of cancellation. + /// A task that represents the work queued to execute. + public Task OnDeleteActivityAsync(Activity activity, CancellationToken cancellation) + { + TelemetryClient.TrackEvent(TelemetryLoggerConstants.BotMsgDeleteEvent, FillDeleteEventProperties(activity)); + + return Task.CompletedTask; + } + + /// + /// Logs events based on incoming and outgoing activities using the IBotTelemetryClient interface. + /// + /// The context object for this turn. + /// The delegate to call to continue the bot middleware pipeline. + /// A cancellation token that can be used by other objects + /// or threads to receive notice of cancellation. + /// A task that represents the work queued to execute. + /// + /// + public async Task OnTurnAsync(ITurnContext context, NextDelegate nextTurn, CancellationToken cancellationToken) + { + BotAssert.ContextNotNull(context); + + // log incoming activity at beginning of turn + if (context.Activity != null) + { + var activity = context.Activity; + + // Log Bot Message Received + await OnReceiveActivityAsync(activity, cancellationToken).ConfigureAwait(false); + } + + // hook up onSend pipeline + context.OnSendActivities(async (ctx, activities, nextSend) => + { + // run full pipeline + var responses = await nextSend().ConfigureAwait(false); + + foreach (var activity in activities) + { + await OnSendActivityAsync(activity, cancellationToken).ConfigureAwait(false); + } + + return responses; + }); + + // hook up update activity pipeline + context.OnUpdateActivity(async (ctx, activity, nextUpdate) => + { + // run full pipeline + var response = await nextUpdate().ConfigureAwait(false); + + await OnUpdateActivityAsync(activity, cancellationToken).ConfigureAwait(false); + + return response; + }); + + // hook up delete activity pipeline + context.OnDeleteActivity(async (ctx, reference, nextDelete) => + { + // run full pipeline + await nextDelete().ConfigureAwait(false); + + var deleteActivity = new Activity + { + Type = ActivityTypes.MessageDelete, + Id = reference.ActivityId, + } + .ApplyConversationReference(reference, isIncoming: false) + .AsMessageDeleteActivity(); + + await OnDeleteActivityAsync((Activity)deleteActivity, cancellationToken).ConfigureAwait(false); + }); + + if (nextTurn != null) + { + await nextTurn(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Fills the Event properties for BotMessageReceived. + /// These properties are logged in the IBotTelemetryClient.TrackEvent method when a message is received from the user. + /// + /// Last activity sent from user. + /// Additional properties to add to the event. + /// A dictionary that is sent as "Properties" to IBotTelemetryClient.TrackEvent method for the BotMessageReceived event. + protected Dictionary FillReceiveEventProperties(Activity activity, Dictionary additionalProperties = null) + { + var properties = new Dictionary() + { + { TelemetryConstants.FromIdProperty, activity.From.Id }, + { TelemetryConstants.ConversationNameProperty, activity.Conversation.Name }, + { TelemetryConstants.LocaleProperty, activity.Locale }, + { TelemetryConstants.RecipientIdProperty, activity.Recipient.Id }, + { TelemetryConstants.RecipientNameProperty, activity.Recipient.Name }, + }; + + // Use the LogPersonalInformation flag to toggle logging PII data, text and user name are common examples + if (LogPersonalInformation) + { + if (!string.IsNullOrWhiteSpace(activity.From.Name)) + { + properties.Add(TelemetryConstants.FromNameProperty, activity.From.Name); + } + + if (!string.IsNullOrWhiteSpace(activity.Text)) + { + properties.Add(TelemetryConstants.TextProperty, activity.Text); + } + + if (!string.IsNullOrWhiteSpace(activity.Speak)) + { + properties.Add(TelemetryConstants.SpeakProperty, activity.Speak); + } + } + + // Additional Properties can override "stock" properties. + if (additionalProperties != null) + { + return additionalProperties.Concat(properties) + .GroupBy(kv => kv.Key) + .ToDictionary(g => g.Key, g => g.First().Value); + } + + return properties; + } + + /// + /// Fills the event properties for BotMessageSend. + /// These properties are logged when an activity message is sent by the Bot to the user. + /// + /// Last activity sent from user. + /// Additional properties to add to the event. + /// A dictionary that is sent as "Properties" to IBotTelemetryClient.TrackEvent method for the BotMessageSend event. + protected Dictionary FillSendEvendProperties(Activity activity, Dictionary additionalProperties = null) + { + var properties = new Dictionary() + { + { TelemetryConstants.ReplyActivityIDProperty, activity.ReplyToId }, + { TelemetryConstants.RecipientIdProperty, activity.Recipient.Id }, + { TelemetryConstants.ConversationNameProperty, activity.Conversation.Name }, + { TelemetryConstants.LocaleProperty, activity.Locale }, + }; + + // Use the LogPersonalInformation flag to toggle logging PII data, text and user name are common examples + if (LogPersonalInformation) + { + if (!string.IsNullOrWhiteSpace(activity.Recipient.Name)) + { + properties.Add(TelemetryConstants.RecipientNameProperty, activity.Recipient.Name); + } + + if (!string.IsNullOrWhiteSpace(activity.Text)) + { + properties.Add(TelemetryConstants.TextProperty, activity.Text); + } + + if (!string.IsNullOrWhiteSpace(activity.Speak)) + { + properties.Add(TelemetryConstants.SpeakProperty, activity.Speak); + } + } + + // Additional Properties can override "stock" properties. + if (additionalProperties != null) + { + return additionalProperties.Concat(properties) + .GroupBy(kv => kv.Key) + .ToDictionary(g => g.Key, g => g.First().Value); + } + + return properties; + } + + /// + /// Fills the event properties for BotMessageUpdate. + /// These properties are logged when an activity message is updated by the Bot. + /// For example, if a card is interacted with by the use, and the card needs to be updated to reflect + /// some interaction. + /// + /// Last activity sent from user. + /// Additional properties to add to the event. + /// A dictionary that is sent as "Properties" to IBotTelemetryClient.TrackEvent method for the BotMessageUpdate event. + protected Dictionary FillUpdateEventProperties(Activity activity, Dictionary additionalProperties = null) + { + var properties = new Dictionary() + { + { TelemetryConstants.RecipientIdProperty, activity.Recipient.Id }, + { TelemetryConstants.ConversationIdProperty, activity.Conversation.Id }, + { TelemetryConstants.ConversationNameProperty, activity.Conversation.Name }, + { TelemetryConstants.LocaleProperty, activity.Locale }, + }; + + // Use the LogPersonalInformation flag to toggle logging PII data, text is a common example + if (LogPersonalInformation && !string.IsNullOrWhiteSpace(activity.Text)) + { + properties.Add(TelemetryConstants.TextProperty, activity.Text); + } + + // Additional Properties can override "stock" properties. + if (additionalProperties != null) + { + return additionalProperties.Concat(properties) + .GroupBy(kv => kv.Key) + .ToDictionary(g => g.Key, g => g.First().Value); + } + + return properties; + + return properties; + } + + /// + /// Fills the event properties for BotMessageDelete. + /// These properties are logged when an activity message is deleted by the Bot. + /// + /// The Activity object deleted by bot. + /// Additional properties to add to the event. + /// A dictionary that is sent as "Properties" to IBotTelemetryClient.TrackEvent method for the BotMessageDelete event. + protected Dictionary FillDeleteEventProperties(IMessageDeleteActivity activity, Dictionary additionalProperties = null) + { + var properties = new Dictionary() + { + { TelemetryConstants.RecipientIdProperty, activity.Recipient.Id }, + { TelemetryConstants.ConversationIdProperty, activity.Conversation.Id }, + { TelemetryConstants.ConversationNameProperty, activity.Conversation.Name }, + }; + + // Additional Properties can override "stock" properties. + if (additionalProperties != null) + { + return additionalProperties.Concat(properties) + .GroupBy(kv => kv.Key) + .ToDictionary(g => g.Key, g => g.First().Value); + } + + return properties; + } + } +} diff --git a/tests/Microsoft.Bot.Builder.AI.LUIS.Tests/LuisRecognizerTests.cs b/tests/Microsoft.Bot.Builder.AI.LUIS.Tests/LuisRecognizerTests.cs index a6e1080357..9c50ada62d 100644 --- a/tests/Microsoft.Bot.Builder.AI.LUIS.Tests/LuisRecognizerTests.cs +++ b/tests/Microsoft.Bot.Builder.AI.LUIS.Tests/LuisRecognizerTests.cs @@ -14,6 +14,7 @@ using Microsoft.Bot.Configuration; using Microsoft.Bot.Schema; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using RichardSzalay.MockHttp; @@ -514,6 +515,308 @@ public void UserAgentContainsProductVersion() Assert.IsTrue(userAgent.Contains("Microsoft.Bot.Builder.AI.Luis/4")); } + [TestMethod] + public void Telemetry_Construction() + { + // Arrange + // Note this is NOT a real LUIS application ID nor a real LUIS subscription-key + // theses are GUIDs edited to look right to the parsing and validation code. + var endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q="; + var fieldInfo = typeof(LuisRecognizer).GetField("_application", BindingFlags.NonPublic | BindingFlags.Instance); + + // Act + var recognizer = new LuisRecognizer(endpoint); + + // Assert + var app = (LuisApplication)fieldInfo.GetValue(recognizer); + Assert.AreEqual("b31aeaf3-3511-495b-a07f-571fc873214b", app.ApplicationId); + Assert.AreEqual("048ec46dc58e495482b0c447cfdbd291", app.EndpointKey); + Assert.AreEqual("https://westus.api.cognitive.microsoft.com", app.Endpoint); + } + + [TestMethod] + [TestCategory("Telemetry")] + public async Task Telemetry_OverrideOnLogAsync() + { + // Arrange + // Note this is NOT a real LUIS application ID nor a real LUIS subscription-key + // theses are GUIDs edited to look right to the parsing and validation code. + var endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q="; + var clientHandler = new EmptyLuisResponseClientHandler(); + var luisApp = new LuisApplication(endpoint); + var telemetryClient = new Mock(); + var adapter = new NullAdapter(); + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = "please book from May 5 to June 6", + Recipient = new ChannelAccount(), // to no where + From = new ChannelAccount(), // from no one + Conversation = new ConversationAccount() // on no conversation + }; + + var turnContext = new TurnContext(adapter, activity); + var recognizer = new LuisRecognizer(luisApp, null, false, clientHandler, telemetryClient.Object); + + // Act + var additionalProperties = new Dictionary + { + { "test", "testvalue" }, + { "foo", "foovalue" }, + }; + var result = await recognizer.RecognizeAsync(turnContext, additionalProperties).ConfigureAwait(false); + + // Assert + Assert.AreEqual(telemetryClient.Invocations.Count, 1); + Assert.AreEqual(telemetryClient.Invocations[0].Arguments[0].ToString(), "LuisResult"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("test")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1])["test"] == "testvalue"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("foo")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1])["foo"] == "foovalue"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("applicationId")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("intent")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("intentScore")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("fromId")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("entities")); + + } + + [TestMethod] + [TestCategory("Telemetry")] + public async Task Telemetry_OverrideOnDeriveAsync() + { + // Arrange + // Note this is NOT a real LUIS application ID nor a real LUIS subscription-key + // theses are GUIDs edited to look right to the parsing and validation code. + var endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q="; + var clientHandler = new EmptyLuisResponseClientHandler(); + var luisApp = new LuisApplication(endpoint); + var telemetryClient = new Mock(); + var adapter = new NullAdapter(); + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = "please book from May 5 to June 6", + Recipient = new ChannelAccount(), // to no where + From = new ChannelAccount(), // from no one + Conversation = new ConversationAccount() // on no conversation + }; + + var turnContext = new TurnContext(adapter, activity); + var recognizer = new TelemetryOverrideRecognizer(telemetryClient.Object, luisApp, null, false, false, clientHandler); + + var additionalProperties = new Dictionary + { + { "test", "testvalue" }, + { "foo", "foovalue" }, + }; + var result = await recognizer.RecognizeAsync(turnContext, additionalProperties).ConfigureAwait(false); + + // Assert + Assert.AreEqual(telemetryClient.Invocations.Count, 2); + Assert.AreEqual(telemetryClient.Invocations[0].Arguments[0].ToString(), "LuisResult"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("MyImportantProperty")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1])["MyImportantProperty"] == "myImportantValue"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("test")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1])["test"] == "testvalue"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("foo")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1])["foo"] == "foovalue"); + Assert.AreEqual(telemetryClient.Invocations[1].Arguments[0].ToString(), "MySecondEvent"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[1].Arguments[1]).ContainsKey("MyImportantProperty2")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[1].Arguments[1])["MyImportantProperty2"] == "myImportantValue2"); + } + + [TestMethod] + [TestCategory("Telemetry")] + public async Task Telemetry_OverrideFillAsync() + { + // Arrange + // Note this is NOT a real LUIS application ID nor a real LUIS subscription-key + // theses are GUIDs edited to look right to the parsing and validation code. + var endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q="; + var clientHandler = new EmptyLuisResponseClientHandler(); + var luisApp = new LuisApplication(endpoint); + var telemetryClient = new Mock(); + var adapter = new NullAdapter(); + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = "please book from May 5 to June 6", + Recipient = new ChannelAccount(), // to no where + From = new ChannelAccount(), // from no one + Conversation = new ConversationAccount() // on no conversation + }; + + var turnContext = new TurnContext(adapter, activity); + var recognizer = new OverrideFillRecognizer(telemetryClient.Object, luisApp, null, false, false, clientHandler); + + var additionalProperties = new Dictionary + { + { "test", "testvalue" }, + { "foo", "foovalue" }, + }; + var additionalMetrics = new Dictionary + { + { "moo", 3.14159 }, + { "boo", 2.11 }, + }; + + var result = await recognizer.RecognizeAsync(turnContext, additionalProperties, additionalMetrics).ConfigureAwait(false); + + // Assert + Assert.AreEqual(telemetryClient.Invocations.Count, 2); + Assert.AreEqual(telemetryClient.Invocations[0].Arguments[0].ToString(), "LuisResult"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("MyImportantProperty")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1])["MyImportantProperty"] == "myImportantValue"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("test")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1])["test"] == "testvalue"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("foo")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1])["foo"] == "foovalue"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[2]).ContainsKey("moo")); + Assert.AreEqual(((Dictionary)telemetryClient.Invocations[0].Arguments[2])["moo"], 3.14159); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[2]).ContainsKey("boo")); + Assert.AreEqual(((Dictionary)telemetryClient.Invocations[0].Arguments[2])["boo"], 2.11); + + Assert.AreEqual(telemetryClient.Invocations[1].Arguments[0].ToString(), "MySecondEvent"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[1].Arguments[1]).ContainsKey("MyImportantProperty2")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[1].Arguments[1])["MyImportantProperty2"] == "myImportantValue2"); + } + + [TestMethod] + [TestCategory("Telemetry")] + public async Task Telemetry_NoOverrideAsync() + { + // Arrange + // Note this is NOT a real LUIS application ID nor a real LUIS subscription-key + // theses are GUIDs edited to look right to the parsing and validation code. + var endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q="; + var clientHandler = new EmptyLuisResponseClientHandler(); + var luisApp = new LuisApplication(endpoint); + var telemetryClient = new Mock(); + var adapter = new NullAdapter(); + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = "please book from May 5 to June 6", + Recipient = new ChannelAccount(), // to no where + From = new ChannelAccount(), // from no one + Conversation = new ConversationAccount() // on no conversation + }; + + var turnContext = new TurnContext(adapter, activity); + var recognizer = new LuisRecognizer(luisApp, null, false, clientHandler, telemetryClient.Object); + + // Act + var result = await recognizer.RecognizeAsync(turnContext, CancellationToken.None).ConfigureAwait(false); + + // Assert + Assert.AreEqual(telemetryClient.Invocations.Count, 1); + Assert.AreEqual(telemetryClient.Invocations[0].Arguments[0].ToString(), "LuisResult"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("applicationId")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("intent")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("intentScore")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("fromId")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("entities")); + } + + [TestMethod] + [TestCategory("Telemetry")] + public async Task Telemetry_Convert() + { + // Arrange + // Note this is NOT a real LUIS application ID nor a real LUIS subscription-key + // theses are GUIDs edited to look right to the parsing and validation code. + var endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q="; + var clientHandler = new EmptyLuisResponseClientHandler(); + var luisApp = new LuisApplication(endpoint); + var telemetryClient = new Mock(); + var adapter = new NullAdapter(); + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = "please book from May 5 to June 6", + Recipient = new ChannelAccount(), // to no where + From = new ChannelAccount(), // from no one + Conversation = new ConversationAccount() // on no conversation + }; + + var turnContext = new TurnContext(adapter, activity); + var recognizer = new LuisRecognizer(luisApp, null, false, clientHandler, telemetryClient.Object); + + // Act + // Use a class the converts the Recognizer Result.. + var result = await recognizer.RecognizeAsync(turnContext, CancellationToken.None).ConfigureAwait(false); + + // Assert + Assert.AreEqual(telemetryClient.Invocations.Count, 1); + Assert.AreEqual(telemetryClient.Invocations[0].Arguments[0].ToString(), "LuisResult"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("applicationId")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("intent")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("intentScore")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("fromId")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("entities")); + } + + + + [TestMethod] + [TestCategory("Telemetry")] + public async Task Telemetry_ConvertParms() + { + // Arrange + // Note this is NOT a real LUIS application ID nor a real LUIS subscription-key + // theses are GUIDs edited to look right to the parsing and validation code. + var endpoint = "https://westus.api.cognitive.microsoft.com/luis/v2.0/apps/b31aeaf3-3511-495b-a07f-571fc873214b?verbose=true&timezoneOffset=-360&subscription-key=048ec46dc58e495482b0c447cfdbd291&q="; + var clientHandler = new EmptyLuisResponseClientHandler(); + var luisApp = new LuisApplication(endpoint); + var telemetryClient = new Mock(); + var adapter = new NullAdapter(); + var activity = new Activity + { + Type = ActivityTypes.Message, + Text = "please book from May 5 to June 6", + Recipient = new ChannelAccount(), // to no where + From = new ChannelAccount(), // from no one + Conversation = new ConversationAccount() // on no conversation + }; + + var turnContext = new TurnContext(adapter, activity); + var recognizer = new LuisRecognizer(luisApp, null, false, clientHandler, telemetryClient.Object); + + // Act + var additionalProperties = new Dictionary + { + { "test", "testvalue" }, + { "foo", "foovalue" }, + }; + var additionalMetrics = new Dictionary + { + { "moo", 3.14159 }, + { "luis", 1.0001 }, + }; + + var result = await recognizer.RecognizeAsync(turnContext, additionalProperties, additionalMetrics, CancellationToken.None).ConfigureAwait(false); + + // Assert + Assert.AreEqual(telemetryClient.Invocations.Count, 1); + Assert.AreEqual(telemetryClient.Invocations[0].Arguments[0].ToString(), "LuisResult"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("test")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1])["test"] == "testvalue"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("foo")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1])["foo"] == "foovalue"); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("applicationId")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("intent")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("intentScore")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("fromId")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[1]).ContainsKey("entities")); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[2]).ContainsKey("moo")); + Assert.AreEqual(((Dictionary)telemetryClient.Invocations[0].Arguments[2])["moo"], 3.14159); + Assert.IsTrue(((Dictionary)telemetryClient.Invocations[0].Arguments[2]).ContainsKey("luis")); + Assert.AreEqual(((Dictionary)telemetryClient.Invocations[0].Arguments[2])["luis"], 1.0001); + } + + + // Compare two JSON structures and ensure entity and intent scores are within delta private bool WithinDelta(JToken token1, JToken token2, double delta, bool compare = false) { @@ -650,6 +953,79 @@ private string GetFilePath(string fileName) } } + public class TelemetryOverrideRecognizer : LuisRecognizer + { + public TelemetryOverrideRecognizer(IBotTelemetryClient telemetryClient, LuisApplication application, LuisPredictionOptions predictionOptions = null, bool includeApiResults = false, bool logPersonalInformation = false, HttpClientHandler clientHandler = null) + : base(application, predictionOptions, includeApiResults, clientHandler, telemetryClient) + { + LogPersonalInformation = logPersonalInformation; + } + + override protected Task OnRecognizerResultAsync(RecognizerResult recognizerResult, ITurnContext turnContext, Dictionary properties = null, Dictionary metrics = null, CancellationToken cancellationToken = default(CancellationToken)) + { + properties.TryAdd("MyImportantProperty", "myImportantValue"); + // Log event + TelemetryClient.TrackEvent( + LuisTelemetryConstants.LuisResult, + properties, + metrics); + // Create second event. + var secondEventProperties = new Dictionary(); + secondEventProperties.Add("MyImportantProperty2", + "myImportantValue2"); + TelemetryClient.TrackEvent( + "MySecondEvent", + secondEventProperties); + return Task.CompletedTask; + } + } + + public class OverrideFillRecognizer : LuisRecognizer + { + public OverrideFillRecognizer(IBotTelemetryClient telemetryClient, LuisApplication application, LuisPredictionOptions predictionOptions = null, bool includeApiResults = false, bool logPersonalInformation = false, HttpClientHandler clientHandler = null) + : base(application, predictionOptions, includeApiResults, clientHandler, telemetryClient) + { + LogPersonalInformation = logPersonalInformation; + } + + override protected async Task OnRecognizerResultAsync(RecognizerResult recognizerResult, ITurnContext turnContext, Dictionary telemetryProperties = null, Dictionary telemetryMetrics = null, CancellationToken cancellationToken = default(CancellationToken)) + { + var properties = await FillLuisEventPropertiesAsync(recognizerResult, turnContext, telemetryProperties, cancellationToken).ConfigureAwait(false); + + properties.TryAdd("MyImportantProperty", "myImportantValue"); + // Log event + TelemetryClient.TrackEvent( + LuisTelemetryConstants.LuisResult, + properties, + telemetryMetrics); + + // Create second event. + var secondEventProperties = new Dictionary(); + secondEventProperties.Add("MyImportantProperty2", + "myImportantValue2"); + TelemetryClient.TrackEvent( + "MySecondEvent", + secondEventProperties); + } + } + + public class TelemetryConvertResult : IRecognizerConvert + { + RecognizerResult _result; + public TelemetryConvertResult() + { + } + + /// + /// Convert recognizer result. + /// + /// Result to convert. + public void Convert(dynamic result) + { + _result = result as RecognizerResult; + } + } + public class MockedHttpClientHandler : HttpClientHandler { private readonly HttpClient client; diff --git a/tests/Microsoft.Bot.Builder.Tests/TelemetryMiddlewareTests.cs b/tests/Microsoft.Bot.Builder.Tests/TelemetryMiddlewareTests.cs new file mode 100644 index 0000000000..c82685ab2f --- /dev/null +++ b/tests/Microsoft.Bot.Builder.Tests/TelemetryMiddlewareTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Bot.Builder.Adapters; +using Microsoft.Bot.Schema; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Newtonsoft.Json; + +namespace Microsoft.Bot.Builder.Tests +{ + [TestClass] + public class TelemetryMiddlewareTests + { + [TestMethod] + [TestCategory("Telemetry")] + public async Task Telemetry_LogActivities() + { + var mockTelemetryClient = new Mock(); + TestAdapter adapter = new TestAdapter() + .Use(new TelemetryLoggerMiddleware(mockTelemetryClient.Object, logPersonalInformation: true)); + string conversationId = null; + + await new TestFlow(adapter, async (context, cancellationToken) => + { + conversationId = context.Activity.Conversation.Id; + var typingActivity = new Activity + { + Type = ActivityTypes.Typing, + RelatesTo = context.Activity.RelatesTo + }; + await context.SendActivityAsync(typingActivity); + await Task.Delay(500); + await context.SendActivityAsync("echo:" + context.Activity.Text); + }) + .Send("foo") + .AssertReply((activity) => Assert.AreEqual(activity.Type, ActivityTypes.Typing)) + .AssertReply("echo:foo") + .Send("bar") + .AssertReply((activity) => Assert.AreEqual(activity.Type, ActivityTypes.Typing)) + .AssertReply("echo:bar") + .StartTestAsync(); + + Assert.AreEqual(mockTelemetryClient.Invocations.Count, 6); + Assert.AreEqual(mockTelemetryClient.Invocations[0].Arguments[0], "BotMessageReceived"); // Check initial message + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[0].Arguments[1]).Count == 7); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[0].Arguments[1]).ContainsKey("fromId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[0].Arguments[1]).ContainsKey("conversationName")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[0].Arguments[1]).ContainsKey("locale")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[0].Arguments[1]).ContainsKey("recipientId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[0].Arguments[1]).ContainsKey("recipientName")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[0].Arguments[1]).ContainsKey("fromName")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[0].Arguments[1]).ContainsKey("text")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[0].Arguments[1])["text"] == "foo"); + + Assert.AreEqual(mockTelemetryClient.Invocations[1].Arguments[0], "BotMessageSend"); // Check Typing message + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[1].Arguments[1]).Count == 5); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[1].Arguments[1]).ContainsKey("replyActivityId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[1].Arguments[1]).ContainsKey("recipientId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[1].Arguments[1]).ContainsKey("conversationName")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[1].Arguments[1]).ContainsKey("locale")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[1].Arguments[1]).ContainsKey("recipientName")); + + Assert.AreEqual(mockTelemetryClient.Invocations[2].Arguments[0], "BotMessageSend"); // Check message reply + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[2].Arguments[1]).Count == 6); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[2].Arguments[1]).ContainsKey("replyActivityId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[2].Arguments[1]).ContainsKey("recipientId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[2].Arguments[1]).ContainsKey("conversationName")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[2].Arguments[1]).ContainsKey("locale")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[2].Arguments[1]).ContainsKey("recipientName")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[2].Arguments[1]).ContainsKey("text")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[2].Arguments[1])["text"] == "echo:foo"); + + Assert.AreEqual(mockTelemetryClient.Invocations[3].Arguments[0], "BotMessageReceived"); // Check bar message + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).Count == 7); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("fromId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("conversationName")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("locale")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("recipientId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("recipientName")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("fromName")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("text")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1])["text"] == "bar"); + } + + [TestMethod] + [TestCategory("Telemetry")] + public async Task Transcript_LogUpdateActivities() + { + var mockTelemetryClient = new Mock(); + TestAdapter adapter = new TestAdapter() + .Use(new TelemetryLoggerMiddleware(mockTelemetryClient.Object, logPersonalInformation: true)); + string conversationId = null; + Activity activityToUpdate = null; + await new TestFlow(adapter, async (context, cancellationToken) => + { + conversationId = context.Activity.Conversation.Id; + if (context.Activity.Text == "update") + { + activityToUpdate.Text = "new response"; + await context.UpdateActivityAsync(activityToUpdate); + } + else + { + var activity = context.Activity.CreateReply("response"); + var response = await context.SendActivityAsync(activity); + activity.Id = response.Id; + + // clone the activity, so we can use it to do an update + activityToUpdate = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(activity)); + } + }) + .Send("foo") + .Send("update") + .AssertReply("new response") + .StartTestAsync(); + + Assert.AreEqual(mockTelemetryClient.Invocations.Count, 4); + Assert.AreEqual(mockTelemetryClient.Invocations[3].Arguments[0], "BotMessageUpdate"); // Check update message + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).Count == 5); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("recipientId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("conversationId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("conversationName")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("locale")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("text")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1])["text"] == "new response"); + } + + + [TestMethod] + [TestCategory("Middleware")] + public async Task Transcript_LogDeleteActivities() + { + var mockTelemetryClient = new Mock(); + TestAdapter adapter = new TestAdapter() + .Use(new TelemetryLoggerMiddleware(mockTelemetryClient.Object, logPersonalInformation: true)); + string conversationId = null; + string activityId = null; + await new TestFlow(adapter, async (context, cancellationToken) => + { + conversationId = context.Activity.Conversation.Id; + if (context.Activity.Text == "deleteIt") + { + await context.DeleteActivityAsync(activityId); + } + else + { + var activity = context.Activity.CreateReply("response"); + var response = await context.SendActivityAsync(activity); + activityId = response.Id; + } + }) + .Send("foo") + .AssertReply("response") + .Send("deleteIt") + .StartTestAsync(); + Assert.AreEqual(mockTelemetryClient.Invocations.Count, 4); + Assert.AreEqual(mockTelemetryClient.Invocations[3].Arguments[0], "BotMessageDelete"); // Check delete message + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).Count == 3); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("recipientId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("conversationId")); + Assert.IsTrue(((Dictionary)mockTelemetryClient.Invocations[3].Arguments[1]).ContainsKey("conversationName")); + } + } +}