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
@@ -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 @@
@@ -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
{