diff --git a/LearningHub.Nhs.WebUI/Controllers/Api/SearchController.cs b/LearningHub.Nhs.WebUI/Controllers/Api/SearchController.cs index 1c7eef217..4d7cb0865 100644 --- a/LearningHub.Nhs.WebUI/Controllers/Api/SearchController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/Api/SearchController.cs @@ -1,4 +1,4 @@ -namespace LearningHub.Nhs.WebUI.Controllers.Api +namespace LearningHub.Nhs.WebUI.Controllers.Api { using System; using System.Linq; @@ -8,6 +8,7 @@ using LearningHub.Nhs.WebUI.Helpers; using LearningHub.Nhs.WebUI.Interfaces; using LearningHub.Nhs.WebUI.Models; + using LearningHub.Nhs.WebUI.Models.Search; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -20,14 +21,17 @@ public class SearchController : ControllerBase { private readonly ISearchService searchService; + private readonly ISearchTelemetryService searchTelemetryService; /// /// Initializes a new instance of the class. /// /// Resource service. - public SearchController(ISearchService searchService) + /// Search telemetry service. + public SearchController(ISearchService searchService, ISearchTelemetryService searchTelemetryService) { this.searchService = searchService; + this.searchTelemetryService = searchTelemetryService; } /// @@ -145,5 +149,59 @@ public async Task RecordClickedCatalogueSearchResult(SearchAction return this.Ok(await this.searchService.CreateCatalogueSearchActionAsync(searchActionCatalogueModel)); } + + /// + /// Records search result click telemetry for Azure Search observability. + /// + /// The click telemetry payload. + /// An . + [HttpPost("RecordResultClickTelemetry")] + public async Task RecordResultClickTelemetry(SearchResultClickTelemetryModel model) + { + if (model == null || string.IsNullOrWhiteSpace(model.ResultUrl)) + { + return this.BadRequest(); + } + + await this.searchTelemetryService.RecordResultClickTelemetryAsync(model); + + return this.Ok(); + } + + /// + /// Records search executed telemetry for zero-result rate analysis. + /// + /// The search executed telemetry payload. + /// An . + // [HttpPost("RecordSearchExecutedTelemetry")] + // public async Task RecordSearchExecutedTelemetry(SearchExecutedTelemetryModel model) + // { + // if (model == null || string.IsNullOrWhiteSpace(model.QueryText)) + // { + // return this.BadRequest(); + // } + + // await this.searchTelemetryService.RecordSearchExecutedFromApiAsync(model); + + // return this.Ok(); + // } + + /// + /// Records search facet applied telemetry for facet usage analysis. + /// + /// The facet applied telemetry payload. + /// An . + [HttpPost("RecordFacetAppliedTelemetry")] + public async Task RecordFacetAppliedTelemetry(SearchFacetAppliedTelemetryModel model) + { + if (model == null || string.IsNullOrWhiteSpace(model.FacetField)) + { + return this.BadRequest(); + } + + await this.searchTelemetryService.RecordFacetAppliedTelemetryAsync(model); + + return this.Ok(); + } } } diff --git a/LearningHub.Nhs.WebUI/Controllers/SearchController.cs b/LearningHub.Nhs.WebUI/Controllers/SearchController.cs index 3f4cf8766..4bed82eae 100644 --- a/LearningHub.Nhs.WebUI/Controllers/SearchController.cs +++ b/LearningHub.Nhs.WebUI/Controllers/SearchController.cs @@ -2,9 +2,11 @@ namespace LearningHub.Nhs.WebUI.Controllers { using System; using System.Collections.Generic; + using System.Diagnostics; using System.Linq; using System.Net.Http; using System.Threading.Tasks; + using LearningHub.Nhs.Models.Extensions; using LearningHub.Nhs.Models.Search; using LearningHub.Nhs.Models.Search.SearchClick; using LearningHub.Nhs.WebUI.Filters; @@ -31,6 +33,7 @@ public class SearchController : BaseController private readonly ISearchService searchService; private readonly IFileService fileService; private readonly IFeatureManager featureManager; + private readonly ISearchTelemetryService searchTelemetryService; /// /// Initializes a new instance of the class. @@ -43,6 +46,7 @@ public class SearchController : BaseController /// The fileService. /// The Feature flag manager. /// moodleBridgeApiService. + /// Search telemetry service. public SearchController( IHttpClientFactory httpClientFactory, IWebHostEnvironment hostingEnvironment, @@ -51,12 +55,14 @@ public SearchController( ILogger logger, IFileService fileService, IMoodleBridgeApiService moodleBridgeApiService, - IFeatureManager featureManager) + IFeatureManager featureManager, + ISearchTelemetryService searchTelemetryService) : base(hostingEnvironment, httpClientFactory, logger, moodleBridgeApiService, settings.Value) { this.searchService = searchService; this.fileService = fileService; this.featureManager = featureManager; + this.searchTelemetryService = searchTelemetryService; } /// @@ -77,6 +83,8 @@ public async Task Index(SearchRequestViewModel search, bool noSor var azureSearchEnabled = Task.Run(() => this.featureManager.IsEnabledAsync(FeatureFlags.AzureSearch)).Result; SearchResultViewModel searchResult = new SearchResultViewModel(); + var stopwatch = Stopwatch.StartNew(); + if (azureSearchEnabled) { searchResult = await this.searchService.PerformSearch(this.User, search); @@ -86,6 +94,8 @@ public async Task Index(SearchRequestViewModel search, bool noSor searchResult = await this.searchService.PerformSearchInFindwise(this.User, search); } + stopwatch.Stop(); + if (search.SearchId == 0 && searchResult.ResourceSearchResult != null) { var searchId = await this.searchService.RegisterSearchEventsAsync( @@ -99,6 +109,9 @@ public async Task Index(SearchRequestViewModel search, bool noSor { searchResult.CatalogueSearchResult.SearchId = searchId; } + + // Record SearchExecutedTelemetry for zero-result rate analysis + await this.searchTelemetryService.RecordSearchExecutedAsync(search, searchResult, this.User.Identity.GetCurrentUserId(), stopwatch.ElapsedMilliseconds); } if (filterApplied) @@ -177,6 +190,9 @@ public async Task IndexPost([FromQuery] SearchRequestViewModel se return await this.Index(search, noSortFilterError: true); } + // Record facet telemetry when filters are changed + await this.RecordFacetChangesAsync(search, filterUpdated, newFilters, existingFilters, resourceAccessLevelFilterUpdated, resourceAccessLevelId, search.ResourceAccessLevelId, filterProviderUpdated, newProviderFilters, existingProviderFilters, filterResourceCollectionUpdated, newResourceCollectionFilter, existingResourceCollectionFilter); + if (search.ResourcePageIndex > 0 && (filterUpdated || resourceAccessLevelFilterUpdated || filterProviderUpdated || filterResourceCollectionUpdated)) { search.ResourcePageIndex = null; @@ -435,5 +451,195 @@ public IActionResult RecordAutoSuggestionClick(string term, string url, string c this.searchService.SendAutoSuggestionClickActionAsync(clickPayloadModel); return this.Redirect(url); } + + /// + /// Records facet changes when filters are applied via the Apply button. + /// + /// The current search request. + /// Whether resource type filters were updated. + /// The new resource type filters. + /// The existing resource type filters. + /// Whether resource access level filter was updated. + /// The new resource access level filter id. + /// The existing resource access level filter id. + /// Whether provider filters were updated. + /// The new provider filters. + /// The existing provider filters. + /// Whether resource collection filters were updated. + /// The new resource collection filters. + /// The existing resource collection filters. + /// A task that represents the asynchronous operation. + private async Task RecordFacetChangesAsync( + SearchRequestViewModel search, + bool filterUpdated, + IOrderedEnumerable newFilters, + IOrderedEnumerable existingFilters, + bool resourceAccessLevelFilterUpdated, + int? newAccessLevelId, + int? existingAccessLevelId, + bool filterProviderUpdated, + IOrderedEnumerable newProviderFilters, + IOrderedEnumerable existingProviderFilters, + bool filterResourceCollectionUpdated, + IOrderedEnumerable newResourceCollectionFilter, + IOrderedEnumerable existingResourceCollectionFilter) + { + var correlationId = search.SearchId.ToString(); + var sessionId = search.GroupId ?? string.Empty; + var queryText = search.Term ?? string.Empty; + + // Record resource type filter changes + if (filterUpdated) + { + var addedFilters = newFilters.Except(existingFilters); + var removedFilters = existingFilters.Except(newFilters); + + foreach (var filter in addedFilters) + { + var model = new SearchFacetAppliedTelemetryModel + { + CorrelationId = correlationId, + SessionId = sessionId, + QueryText = queryText, + QueryMode = "standard", + FacetField = "ResourceType", + FacetValue = filter, + FacetAction = "applied", + }; + + await this.searchTelemetryService.RecordFacetAppliedTelemetryAsync(model); + } + + foreach (var filter in removedFilters) + { + var model = new SearchFacetAppliedTelemetryModel + { + CorrelationId = correlationId, + SessionId = sessionId, + QueryText = queryText, + QueryMode = "standard", + FacetField = "ResourceType", + FacetValue = filter, + FacetAction = "removed", + }; + + await this.searchTelemetryService.RecordFacetAppliedTelemetryAsync(model); + } + } + + // Record resource access level filter changes + if (resourceAccessLevelFilterUpdated) + { + if (existingAccessLevelId.HasValue && existingAccessLevelId > 0) + { + var model = new SearchFacetAppliedTelemetryModel + { + CorrelationId = correlationId, + SessionId = sessionId, + QueryText = queryText, + QueryMode = "standard", + FacetField = "AudienceAccessLevel", + FacetValue = existingAccessLevelId.ToString(), + FacetAction = "removed", + }; + + await this.searchTelemetryService.RecordFacetAppliedTelemetryAsync(model); + } + + if (newAccessLevelId.HasValue && newAccessLevelId > 0) + { + var model = new SearchFacetAppliedTelemetryModel + { + CorrelationId = correlationId, + SessionId = sessionId, + QueryText = queryText, + QueryMode = "standard", + FacetField = "AudienceAccessLevel", + FacetValue = newAccessLevelId.ToString(), + FacetAction = "applied", + }; + + await this.searchTelemetryService.RecordFacetAppliedTelemetryAsync(model); + } + } + + // Record provider filter changes + if (filterProviderUpdated) + { + var addedProviders = newProviderFilters.Except(existingProviderFilters); + var removedProviders = existingProviderFilters.Except(newProviderFilters); + + foreach (var provider in addedProviders) + { + var model = new SearchFacetAppliedTelemetryModel + { + CorrelationId = correlationId, + SessionId = sessionId, + QueryText = queryText, + QueryMode = "standard", + FacetField = "Provider", + FacetValue = provider, + FacetAction = "applied", + }; + + await this.searchTelemetryService.RecordFacetAppliedTelemetryAsync(model); + } + + foreach (var provider in removedProviders) + { + var model = new SearchFacetAppliedTelemetryModel + { + CorrelationId = correlationId, + SessionId = sessionId, + QueryText = queryText, + QueryMode = "standard", + FacetField = "Provider", + FacetValue = provider, + FacetAction = "removed", + }; + + await this.searchTelemetryService.RecordFacetAppliedTelemetryAsync(model); + } + } + + // Record resource collection filter changes + if (filterResourceCollectionUpdated) + { + var addedCollections = newResourceCollectionFilter.Except(existingResourceCollectionFilter); + var removedCollections = existingResourceCollectionFilter.Except(newResourceCollectionFilter); + + foreach (var collection in addedCollections) + { + var model = new SearchFacetAppliedTelemetryModel + { + CorrelationId = correlationId, + SessionId = sessionId, + QueryText = queryText, + QueryMode = "standard", + FacetField = "ResourceCollection", + FacetValue = collection, + FacetAction = "applied", + }; + + await this.searchTelemetryService.RecordFacetAppliedTelemetryAsync(model); + } + + foreach (var collection in removedCollections) + { + var model = new SearchFacetAppliedTelemetryModel + { + CorrelationId = correlationId, + SessionId = sessionId, + QueryText = queryText, + QueryMode = "standard", + FacetField = "ResourceCollection", + FacetValue = collection, + FacetAction = "removed", + }; + + await this.searchTelemetryService.RecordFacetAppliedTelemetryAsync(model); + } + } + } } } \ No newline at end of file diff --git a/LearningHub.Nhs.WebUI/Interfaces/ISearchTelemetryService.cs b/LearningHub.Nhs.WebUI/Interfaces/ISearchTelemetryService.cs new file mode 100644 index 000000000..67004747e --- /dev/null +++ b/LearningHub.Nhs.WebUI/Interfaces/ISearchTelemetryService.cs @@ -0,0 +1,42 @@ +namespace LearningHub.Nhs.WebUI.Interfaces +{ + using System.Threading.Tasks; + using LearningHub.Nhs.WebUI.Models.Search; + + /// + /// Defines the . + /// + public interface ISearchTelemetryService + { + /// + /// Records search executed telemetry for zero-result rate analysis and latency measurement. + /// + /// The search request view model. + /// The search result view model containing results. + /// The user performing the search. + /// The search execution latency in milliseconds. + /// A representing the asynchronous operation. + Task RecordSearchExecutedAsync(SearchRequestViewModel search, SearchResultViewModel searchResult, int userId, long latencyMs); + + /// + /// Records search result click telemetry for click-through analysis. + /// + /// The search result click telemetry model. + /// A representing the asynchronous operation. + Task RecordResultClickTelemetryAsync(SearchResultClickTelemetryModel model); + + /// + /// Records search executed telemetry from API endpoints. + /// + /// The search executed telemetry model. + /// A representing the asynchronous operation. + // Task RecordSearchExecutedFromApiAsync(SearchExecutedTelemetryModel model); + + /// + /// Records search facet applied telemetry for facet usage analysis. + /// + /// The search facet applied telemetry model. + /// A representing the asynchronous operation. + Task RecordFacetAppliedTelemetryAsync(SearchFacetAppliedTelemetryModel model); + } +} diff --git a/LearningHub.Nhs.WebUI/Models/Search/SearchExecutedTelemetryModel.cs b/LearningHub.Nhs.WebUI/Models/Search/SearchExecutedTelemetryModel.cs new file mode 100644 index 000000000..1bada34cc --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/Search/SearchExecutedTelemetryModel.cs @@ -0,0 +1,48 @@ +namespace LearningHub.Nhs.WebUI.Models.Search +{ + /// + /// Defines telemetry data for a search executed event. + /// + public class SearchExecutedTelemetryModel + { + /// + /// Gets or sets the correlation id for the search request. + /// + public string CorrelationId { get; set; } + + /// + /// Gets or sets the session id for the search request. + /// + public string SessionId { get; set; } + + /// + /// Gets or sets the query text. + /// + public string QueryText { get; set; } + + /// + /// Gets or sets the query mode (keyword, hybrid, semantic). + /// + public string QueryMode { get; set; } + + /// + /// Gets or sets a value indicating whether semantic reranker was used. + /// + public bool UseSemanticReranker { get; set; } + + /// + /// Gets or sets the result type. + /// + public string ResultType { get; set; } + + /// + /// Gets or sets the count of results returned. + /// + public int ResultCount { get; set; } + + /// + /// Gets or sets the latency in milliseconds. + /// + public long LatencyMs { get; set; } + } +} diff --git a/LearningHub.Nhs.WebUI/Models/Search/SearchFacetAppliedTelemetryModel.cs b/LearningHub.Nhs.WebUI/Models/Search/SearchFacetAppliedTelemetryModel.cs new file mode 100644 index 000000000..a35e4d46d --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/Search/SearchFacetAppliedTelemetryModel.cs @@ -0,0 +1,43 @@ +namespace LearningHub.Nhs.WebUI.Models.Search +{ + /// + /// Defines telemetry data for a search facet applied event. + /// + public class SearchFacetAppliedTelemetryModel + { + /// + /// Gets or sets the correlation id for the originating search request. + /// + public string CorrelationId { get; set; } + + /// + /// Gets or sets the session id for the search session. + /// + public string SessionId { get; set; } + + /// + /// Gets or sets the query text. + /// + public string QueryText { get; set; } + + /// + /// Gets or sets the query mode. + /// + public string QueryMode { get; set; } + + /// + /// Gets or sets the facet field name (e.g. "ResultType", "Category"). + /// + public string FacetField { get; set; } + + /// + /// Gets or sets the facet value (e.g. "Guidance", "Policy"). + /// + public string FacetValue { get; set; } + + /// + /// Gets or sets the facet action ("applied", "removed", or "cleared"). + /// + public string FacetAction { get; set; } + } +} diff --git a/LearningHub.Nhs.WebUI/Models/Search/SearchResultClickTelemetryModel.cs b/LearningHub.Nhs.WebUI/Models/Search/SearchResultClickTelemetryModel.cs new file mode 100644 index 000000000..2537fba69 --- /dev/null +++ b/LearningHub.Nhs.WebUI/Models/Search/SearchResultClickTelemetryModel.cs @@ -0,0 +1,68 @@ +namespace LearningHub.Nhs.WebUI.Models.Search +{ + /// + /// Defines telemetry data for a search result click event. + /// + public class SearchResultClickTelemetryModel + { + /// + /// Gets or sets the correlation id for the originating search request. + /// + public string CorrelationId { get; set; } + + /// + /// Gets or sets the session id for the originating search request. + /// + public string SessionId { get; set; } + + /// + /// Gets or sets the query text. + /// + public string QueryText { get; set; } + + /// + /// Gets or sets the query mode. + /// + public string QueryMode { get; set; } + + /// + /// Gets or sets the clicked result url. + /// + public string ResultUrl { get; set; } + + /// + /// Gets or sets the clicked result title. + /// + public string ResultTitle { get; set; } + + /// + /// Gets or sets the clicked result rank. + /// + public int ResultRank { get; set; } + + /// + /// Gets or sets the resource reference id. + /// + public int ResourceReferenceId { get; set; } + + /// + /// Gets or sets the node path id. + /// + public int NodePathId { get; set; } + + /// + /// Gets or sets the result type. + /// + public string ResultType { get; set; } + + /// + /// Gets or sets a value indicating whether result opened in new tab. + /// + public bool OpenInNewTab { get; set; } + + /// + /// Gets or sets the interaction type (click/keyboard/auxclick). + /// + public string InteractionType { get; set; } + } +} diff --git a/LearningHub.Nhs.WebUI/Services/SearchTelemetryService.cs b/LearningHub.Nhs.WebUI/Services/SearchTelemetryService.cs new file mode 100644 index 000000000..a4c09554d --- /dev/null +++ b/LearningHub.Nhs.WebUI/Services/SearchTelemetryService.cs @@ -0,0 +1,205 @@ +namespace LearningHub.Nhs.WebUI.Services +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using LearningHub.Nhs.WebUI.Interfaces; + using LearningHub.Nhs.WebUI.Models.Search; + using Microsoft.ApplicationInsights; + using Microsoft.Extensions.Logging; + + /// + /// Defines the . + /// + public class SearchTelemetryService : ISearchTelemetryService + { + private readonly TelemetryClient telemetryClient; + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// Application Insights telemetry client. + /// Logger instance. + public SearchTelemetryService(TelemetryClient telemetryClient, ILogger logger) + { + this.telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Records search executed telemetry for zero-result rate analysis and latency measurement. + /// + /// The search request view model. + /// The search result view model containing results. + /// The user performing the search. + /// The search execution latency in milliseconds. + /// A representing the asynchronous operation. + public Task RecordSearchExecutedAsync(SearchRequestViewModel search, SearchResultViewModel searchResult, int userId, long latencyMs) + { + if (searchResult == null || string.IsNullOrWhiteSpace(search?.Term)) + { + return Task.CompletedTask; + } + + try + { + // Calculate total result count from both resource and catalogue results + int resultCount = (searchResult.ResourceSearchResult?.TotalHits ?? 0) + + (searchResult.CatalogueSearchResult?.TotalHits ?? 0); + + var groupId = search.GroupId ?? Guid.NewGuid().ToString(); + + // Use search ID or generate new correlation ID + var correlationId = groupId; + + var properties = new Dictionary + { + { "CorrelationId", correlationId }, + { "SessionId", userId.ToString() }, + { "QueryText", search.Term ?? string.Empty }, + { "QueryMode", "standard" }, + { "UseSemanticReranker", "false" }, + { "ResultType", "combined" }, // Could be resource, catalogue, or combined + }; + + var metrics = new Dictionary + { + { "ResultCount", resultCount }, + { "LatencyMs", latencyMs }, + }; + + this.telemetryClient.TrackEvent("SearchExecutedTelemetry", properties, metrics); + } + catch (Exception ex) + { + // Log the exception but don't let telemetry errors impact search functionality + this.logger.LogError(ex, "Failed to record SearchExecutedTelemetry for query: {QueryText}", search?.Term); + } + + return Task.CompletedTask; + } + + /// + /// Records search result click telemetry for click-through analysis. + /// + /// The search result click telemetry model. + /// A representing the asynchronous operation. + public Task RecordResultClickTelemetryAsync(SearchResultClickTelemetryModel model) + { + if (model == null || string.IsNullOrWhiteSpace(model.ResultUrl)) + { + return Task.CompletedTask; + } + + try + { + var properties = new Dictionary + { + { "CorrelationId", model.CorrelationId ?? string.Empty }, + { "SessionId", model.SessionId ?? string.Empty }, + { "QueryText", model.QueryText ?? string.Empty }, + { "QueryMode", model.QueryMode ?? string.Empty }, + { "ResultUrl", model.ResultUrl ?? string.Empty }, + { "ResultTitle", model.ResultTitle ?? string.Empty }, + { "ResultType", model.ResultType ?? string.Empty }, + { "OpenInNewTab", model.OpenInNewTab.ToString() }, + { "InteractionType", model.InteractionType ?? string.Empty }, + }; + + var metrics = new Dictionary + { + { "ResultRank", model.ResultRank + 1 }, + { "ResourceReferenceId", model.ResourceReferenceId }, + { "NodePathId", model.NodePathId }, + }; + + this.telemetryClient.TrackEvent("SearchResultClickTelemetry", properties, metrics); + } + catch (Exception ex) + { + // Log the exception but don't let telemetry errors impact search functionality + this.logger.LogError(ex, "Failed to record SearchResultClickTelemetry for result: {ResultTitle}", model?.ResultTitle); + } + + return Task.CompletedTask; + } + + /// + /// Records search executed telemetry from API endpoints. + /// + /// The search executed telemetry model. + /// A representing the asynchronous operation. + // public Task RecordSearchExecutedFromApiAsync(SearchExecutedTelemetryModel model) + // { + // if (model == null || string.IsNullOrWhiteSpace(model.QueryText)) + // { + // return Task.CompletedTask; + // } + + // try + // { + // var properties = new Dictionary + // { + // { "CorrelationId", model.CorrelationId ?? string.Empty }, + // { "SessionId", model.SessionId ?? string.Empty }, + // { "QueryText", model.QueryText ?? string.Empty }, + // { "QueryMode", model.QueryMode ?? string.Empty }, + // { "UseSemanticReranker", model.UseSemanticReranker.ToString() }, + // { "ResultType", model.ResultType ?? string.Empty }, + // }; + + // var metrics = new Dictionary + // { + // { "ResultCount", model.ResultCount }, + // { "LatencyMs", model.LatencyMs }, + // }; + + // this.telemetryClient.TrackEvent("SearchExecutedTelemetry", properties, metrics); + // } + // catch (Exception ex) + // { + // // Log the exception but don't let telemetry errors impact search functionality + // this.logger.LogError(ex, "Failed to record SearchExecutedTelemetry from API for query: {QueryText}", model?.QueryText); + // } + + // return Task.CompletedTask; + // } + + /// + /// Records search facet applied telemetry for facet usage analysis. + /// + /// The search facet applied telemetry model. + /// A representing the asynchronous operation. + public Task RecordFacetAppliedTelemetryAsync(SearchFacetAppliedTelemetryModel model) + { + if (model == null || string.IsNullOrWhiteSpace(model.FacetField)) + { + return Task.CompletedTask; + } + + try + { + var properties = new Dictionary + { + { "CorrelationId", model.CorrelationId ?? string.Empty }, + { "SessionId", model.SessionId ?? string.Empty }, + { "QueryText", model.QueryText ?? string.Empty }, + { "QueryMode", model.QueryMode ?? string.Empty }, + { "FacetField", model.FacetField ?? string.Empty }, + { "FacetValue", model.FacetValue ?? string.Empty }, + { "FacetAction", model.FacetAction ?? string.Empty }, + }; + + this.telemetryClient.TrackEvent("SearchFacetAppliedTelemetry", properties); + } + catch (Exception ex) + { + // Log the exception but don't let telemetry errors impact search functionality + this.logger.LogError(ex, "Failed to record SearchFacetAppliedTelemetry for facet: {FacetField}", model?.FacetField); + } + + return Task.CompletedTask; + } + } +} diff --git a/LearningHub.Nhs.WebUI/Startup/ServiceMappings.cs b/LearningHub.Nhs.WebUI/Startup/ServiceMappings.cs index 589eea428..abcddb9a9 100644 --- a/LearningHub.Nhs.WebUI/Startup/ServiceMappings.cs +++ b/LearningHub.Nhs.WebUI/Startup/ServiceMappings.cs @@ -80,6 +80,7 @@ public static void AddLearningHubMappings(this IServiceCollection services, ICon services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml b/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml index 9783090ba..03b91b1a4 100644 --- a/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml +++ b/LearningHub.Nhs.WebUI/Views/Search/Index.cshtml @@ -11,6 +11,10 @@ } +@section Scripts { + +} +
@if (Model.DidYouMeanEnabled) @@ -104,4 +108,4 @@
- \ No newline at end of file + diff --git a/LearningHub.Nhs.WebUI/Views/Search/_SearchCatalogueResult.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_SearchCatalogueResult.cshtml index 86a7784c1..f2ce69c37 100644 --- a/LearningHub.Nhs.WebUI/Views/Search/_SearchCatalogueResult.cshtml +++ b/LearningHub.Nhs.WebUI/Views/Search/_SearchCatalogueResult.cshtml @@ -2,7 +2,7 @@ @using LearningHub.Nhs.WebUI.Extensions @using LearningHub.Nhs.Models.Search.SearchClick; -@model (LearningHub.Nhs.Models.Search.Document document, string groupId, int currentPage) +@model (LearningHub.Nhs.Models.Search.Document document, string telemetrySessionId, int currentPage) @{ var item = Model.document; @@ -15,7 +15,7 @@ var searchSignal = payload?.SearchSignal; var pagingModel = Model.currentPage; string encodedCatalogueUrl = HttpUtility.UrlEncode("/Catalogue/" + catalogueUrl); - string groupId = Model.groupId;// HttpUtility.UrlEncode(Model.GroupId.ToString()); + string groupId = Model.telemetrySessionId; string searchSignalQueryEncoded = HttpUtility.UrlEncode(HttpUtility.UrlDecode(searchSignal?.Query)); var url = $@"/search/record-catalogue-click?url={encodedCatalogueUrl}&nodePathId={nodePathId}&itemIndex={payload?.HitNumber} @@ -33,7 +33,17 @@
Catalogue

- @item.Title + @item.Title

@@ -42,4 +52,4 @@

- \ No newline at end of file + diff --git a/LearningHub.Nhs.WebUI/Views/Search/_SearchResult.cshtml b/LearningHub.Nhs.WebUI/Views/Search/_SearchResult.cshtml index e251ebca8..b9e80c68a 100644 --- a/LearningHub.Nhs.WebUI/Views/Search/_SearchResult.cshtml +++ b/LearningHub.Nhs.WebUI/Views/Search/_SearchResult.cshtml @@ -15,7 +15,8 @@ var pagingModel = Model.ResourceResultPaging; var index = pagingModel.CurrentPage * pagingModel.PageSize; var searchString = HttpUtility.UrlEncode(Model.SearchString); - string groupId = HttpUtility.UrlEncode(Model.GroupId.ToString()); + string groupId = HttpUtility.UrlEncode(Model.GroupId.ToString()); + string telemetrySessionId = Model.GroupId.ToString(); string GetUrl(int resourceReferenceId, int itemIndex, int nodePathId, SearchClickPayloadModel payload) { @@ -61,7 +62,7 @@ @if (item.ResourceType == "catalogue") { - @await Html.PartialAsync("_SearchCatalogueResult", (item, groupId, pagingModel.CurrentPage)) + @await Html.PartialAsync("_SearchCatalogueResult", (item, telemetrySessionId, pagingModel.CurrentPage)) } else { @@ -71,11 +72,31 @@

@if (item.ResourceType == "moodle") { - @item.Title + @item.Title } else { - @item.Title + @item.Title }

@@ -152,4 +173,4 @@ index++; } -} \ No newline at end of file +} diff --git a/LearningHub.Nhs.WebUI/appsettings.json b/LearningHub.Nhs.WebUI/appsettings.json index 9c76e040a..3261bf58d 100644 --- a/LearningHub.Nhs.WebUI/appsettings.json +++ b/LearningHub.Nhs.WebUI/appsettings.json @@ -7,16 +7,27 @@ "MultiPageFormService": { "Database": "redis" }, - "ApplicationInsights": { - "InstrumentationKey": "" - }, - "Logging": { - "LogLevel": { - "Default": "Error", - "System": "Information", - "Microsoft": "Information" - } + "ApplicationInsights": { + "InstrumentationKey": "", + "ConnectionString": "", + "WorkspaceId": "", + "TenantId": "", + "ClientId": "", + "ClientSecret": "" + }, + "Logging": { + "LogLevel": { + "Default": "Error", + "System": "Information", + "Microsoft": "Information" }, + "ApplicationInsights": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning" + } + } + }, "AllowedHosts": "*", "Settings": { "BuildNumber": "NotSet", diff --git a/LearningHub.Nhs.WebUI/wwwroot/js/search-telemetry.js b/LearningHub.Nhs.WebUI/wwwroot/js/search-telemetry.js new file mode 100644 index 000000000..08d198f27 --- /dev/null +++ b/LearningHub.Nhs.WebUI/wwwroot/js/search-telemetry.js @@ -0,0 +1,120 @@ +window.LHGlobal = window.LHGlobal || {}; + +LHGlobal.searchClickTelemetry = new function () { + var endpoint = '/api/Search/RecordResultClickTelemetry'; + var selector = 'a[data-search-click-telemetry="true"]'; + var lastEventKey = null; + var lastEventAt = 0; + + var isNewTab = function (e, link) { + return e.button === 1 || e.ctrlKey || e.metaKey || e.shiftKey || link.target === '_blank'; + }; + + var isMatchingElement = function (element) { + if (!element || element.nodeType !== 1) { + return false; + } + + if (element.matches) { + return element.matches(selector); + } + + if (element.msMatchesSelector) { + return element.msMatchesSelector(selector); + } + + return false; + }; + + var findMatchingLink = function (element) { + var currentElement = element; + while (currentElement) { + if (isMatchingElement(currentElement)) { + return currentElement; + } + + currentElement = currentElement.parentElement; + } + + return null; + }; + + var createEventKey = function (link, e, openInNewTab, interactionType) { + return [ + link.getAttribute('href') || '', + link.dataset.correlationId || '', + link.dataset.resultRank || '', + e.button, + openInNewTab ? '1' : '0', + interactionType + ].join('|'); + }; + + var emitTelemetry = function (link, e, interactionType) { + if (!link || !link.dataset) { + return; + } + + var openInNewTab = isNewTab(e, link); + var eventKey = createEventKey(link, e, openInNewTab, interactionType); + var now = Date.now(); + + if (lastEventKey === eventKey && (now - lastEventAt) < 500) { + return; + } + + lastEventKey = eventKey; + lastEventAt = now; + + var body = JSON.stringify({ + correlationId: link.dataset.correlationId || '', + sessionId: link.dataset.sessionId || '', + queryText: link.dataset.queryText || '', + queryMode: link.dataset.queryMode || '', + resultUrl: link.getAttribute('href') || '', + resultTitle: (link.textContent || '').trim(), + resultRank: Number(link.dataset.resultRank || 0), + resourceReferenceId: Number(link.dataset.resourceReferenceId || 0), + nodePathId: Number(link.dataset.nodePathId || 0), + resultType: link.dataset.resultType || '', + openInNewTab: openInNewTab, + interactionType: interactionType + }); + + if (navigator.sendBeacon) { + var blob = new Blob([body], { type: 'application/json; charset=UTF-8' }); + navigator.sendBeacon(endpoint, blob); + return; + } + + if (window.XMLHttpRequest) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', endpoint, true); + xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + xhr.send(body); + } + }; + + document.addEventListener('click', function (e) { + var link = findMatchingLink(e.target); + if (!link) { + return; + } + + var interactionType = e.detail === 0 ? 'keyboard' : 'click'; + emitTelemetry(link, e, interactionType); + }, true); + + document.addEventListener('auxclick', function (e) { + if (e.button !== 1) { + return; + } + + var link = findMatchingLink(e.target); + if (!link) { + return; + } + + emitTelemetry(link, e, 'auxclick'); + }, true); +}; diff --git a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/AzureSearch/AzureSearchService.cs b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/AzureSearch/AzureSearchService.cs index 25a98541e..7f3d54f83 100644 --- a/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/AzureSearch/AzureSearchService.cs +++ b/OpenAPI/LearningHub.Nhs.OpenApi.Services/Services/AzureSearch/AzureSearchService.cs @@ -19,6 +19,7 @@ using LearningHub.Nhs.OpenApi.Services.Helpers; using LearningHub.Nhs.OpenApi.Services.Helpers.Search; using LearningHub.Nhs.OpenApi.Services.Interface.Services; + using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; @@ -134,10 +135,11 @@ public async Task GetSearchResultAsync(SearchRequestModel sea // Map documents var documents = filteredResponse.GetResults() - .Select(result => + .Select((result, index) => { var doc = result.Document; doc.ParseManualTags(); + var absoluteIndex = (searchRequestModel.PageIndex * searchRequestModel.PageSize) + index; return new Document { @@ -162,7 +164,7 @@ public async Task GetSearchResultAsync(SearchRequestModel sea Authors = doc.Author?.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(a => a.Trim()).ToList(), AuthoredDate = doc.DateAuthored?.ToString(), ResourceReferenceId = int.TryParse(doc.ResourceReferenceId, out var id) ? id : 0, - Click = BuildSearchClickModel(doc.Id, doc.Title, searchRequestModel.PageIndex, searchRequestModel.SearchId, filters, query, count) + Click = BuildSearchClickModel(doc.Id, doc.Title, absoluteIndex, searchRequestModel.SearchId, filters, query, count) }; }) .ToList(); @@ -238,17 +240,18 @@ public async Task GetCatalogueSearchResultAsync(Cata var documentList = new CatalogueDocumentList { Documents = response.GetResults() - .Select(result => + .Select((result, index) => { var doc = result.Document; doc.ParseManualTags(); - + var absoluteIndex = (catalogSearchRequestModel.PageIndex * catalogSearchRequestModel.PageSize) + index; + return new CatalogueDocument { Id = doc.Id, Name = doc.Title, Description = doc.Description, - Click = BuildSearchClickModel(doc.Id, doc.Title, catalogSearchRequestModel.PageIndex, catalogSearchRequestModel.SearchId, filters, catalogSearchRequestModel.SearchText, count) + Click = BuildSearchClickModel(doc.Id, doc.Title, absoluteIndex, catalogSearchRequestModel.SearchId, filters, catalogSearchRequestModel.SearchText, count) }; }) .ToArray() @@ -564,10 +567,11 @@ public async Task GetAllCatalogueSearchResultsAsy var documentList = new CatalogueDocumentList { Documents = response.GetResults() - .Select(result => + .Select((result, index) => { var doc = result.Document; doc.ParseManualTags(); + var absoluteIndex = (catalogSearchRequestModel.PageIndex * catalogSearchRequestModel.PageSize) + index; return new CatalogueDocument {