Admin analytics dashboard#454
Conversation
Introduces an admin-only analytics dashboard displaying page views, PDF downloads, and search terms from the past 30 days, powered by Azure Application Insights. Adds AnalyticsService for querying telemetry, new view models, and an AdminController. Updates navigation, DI setup, and configuration to support the new feature.
The analytics dashboard now supports custom date range and month/year filters, replacing the fixed "past 30 days" view. Controller, service, and view logic were updated to accept and display the selected date range. The UI includes new filter controls and quick-select buttons, and all queries now use the specified date range. Documentation and messages were updated for clarity.
- Always register Application Insights; use real connection string if provided - Enable developer mode and debug logging in development - AnalyticsService now auto-detects Azure config and falls back to sample data locally - _Layout.cshtml conditionally loads real or mock AI JS for client-side telemetry - Update appsettings.Development.json for local dev and blob storage emulation - Add ApplicationInsights-Configuration.md with setup and troubleshooting instructions - Ensures analytics dashboard and telemetry work seamlessly in both Azure and local development
Added a button to copy a formatted analytics report to the clipboard from the Analytics page. Implemented a JavaScript function to generate the report using server-side data and provide user feedback on success. Introduced a structured analyticsData object for easier data handling and refactored related logic. Also added utility functions for date/month filter management. No existing features were removed.
Updated analytics queries to exclude bot and synthetic traffic by filtering on `operation_SyntheticSource` and `client_Type`. Added a note to the analytics UI to clarify that metrics reflect only real human users.
Enhanced the page view metrics query in AnalyticsService to filter out dynamic routes such as '/Details', '/Edit', and '/Delete'. This change ensures analytics reports focus on static, meaningful pages and omit less relevant, detail-oriented routes.
Eliminated the ApplicationInsightsPageViewTracker from MVC controller registration and removed the unused PC2.Filters using directive. Controllers are now registered without custom filters.
Reduced the "Year" dropdown options in Analytics.cshtml from six years (current and previous five) to just the current and previous year. This streamlines the selection and focuses analytics on more recent data.
Deleted ApplicationInsights-Configuration.md, which contained setup instructions, environment details, and troubleshooting guidance for Application Insights in both local development and production. All related documentation has been removed.
There was a problem hiding this comment.
Pull request overview
This pull request introduces a comprehensive admin analytics dashboard powered by Azure Application Insights, enabling administrators to view page views, PDF downloads, and search terms through a web interface. The implementation supports both local development with mock data and production environments with real Azure telemetry data.
Key changes:
- New analytics dashboard with date/month filtering and clipboard export functionality
- Azure Application Insights integration using KQL queries for production telemetry
- Removal of duplicate server-side page tracking in favor of client-side JavaScript tracking
- Configuration for both local development and Azure production environments
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| PC2/Controllers/AdminController.cs | New controller with Analytics action to handle date filtering and display analytics data |
| PC2/Services/AnalyticsService.cs | Core service implementing Azure Application Insights queries and local development mock data |
| PC2/Models/AnalyticsViewModel.cs | View models for analytics data (page views, downloads, search terms) |
| PC2/Views/Admin/Analytics.cshtml | Dashboard UI with filtering, statistics cards, and data tables |
| PC2/wwwroot/js/analytics.js | JavaScript for clipboard export and date filtering (duplicated code) |
| PC2/Views/Shared/_Layout.cshtml | Application Insights SDK integration with conditional loading and mock implementation for local dev |
| PC2/Program.cs | Service registration and Application Insights configuration with developer mode support |
| PC2/appsettings.json | Added WorkspaceId configuration for Application Insights |
| PC2/appsettings.Development.json | Local development configuration with mock data enabled |
| PC2/PC2.csproj | Added Azure.Identity and Azure.Monitor.Query packages |
| PC2/Filters/ApplicationInsightsPageViewTracker.cs | Removed obsolete server-side page tracking filter |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Hidden data for clipboard export | ||
| var analyticsData = { | ||
| startDate: '@Model.StartDate?.ToString("MMM dd, yyyy")', | ||
| endDate: '@Model.EndDate?.ToString("MMM dd, yyyy")', | ||
| days: @((Model.EndDate.HasValue && Model.StartDate.HasValue) ? (Model.EndDate.Value - Model.StartDate.Value).Days + 1 : 0), | ||
| totalPageViews: @Model.TotalPageViews, | ||
| totalPdfDownloads: @Model.TotalPdfDownloads, | ||
| totalSearches: @Model.TotalSearches, | ||
| pageViews: [ | ||
| @if (Model.PageViews.Any()) | ||
| { | ||
| for (int i = 0; i < Model.PageViews.Take(20).Count(); i++) | ||
| { | ||
| var pv = Model.PageViews.ElementAt(i); | ||
| var comma = i < Model.PageViews.Take(20).Count() - 1 ? "," : ""; | ||
| <text> { url: '@Html.Raw(System.Text.Json.JsonSerializer.Serialize(pv.PageUrl))', count: @pv.ViewCount }@comma | ||
| </text> | ||
| } | ||
| } | ||
| ], | ||
| pdfDownloads: [ | ||
| @if (Model.PdfDownloads.Any()) | ||
| { | ||
| for (int i = 0; i < Model.PdfDownloads.Count(); i++) | ||
| { | ||
| var dl = Model.PdfDownloads.ElementAt(i); | ||
| var comma = i < Model.PdfDownloads.Count() - 1 ? "," : ""; | ||
| <text> { name: '@Html.Raw(System.Text.Json.JsonSerializer.Serialize(dl.FileName))', count: @dl.DownloadCount }@comma | ||
| </text> | ||
| } | ||
| } | ||
| ], | ||
| searchTerms: [ | ||
| @if (Model.SearchTerms.Any()) | ||
| { | ||
| for (int i = 0; i < Model.SearchTerms.Count(); i++) | ||
| { | ||
| var st = Model.SearchTerms.ElementAt(i); | ||
| var comma = i < Model.SearchTerms.Count() - 1 ? "," : ""; | ||
| <text> { type: '@Html.Raw(System.Text.Json.JsonSerializer.Serialize(st.SearchType))', term: '@Html.Raw(System.Text.Json.JsonSerializer.Serialize(st.SearchTerm))', count: @st.SearchCount }@comma | ||
| </text> | ||
| } | ||
| } | ||
| ] | ||
| }; |
There was a problem hiding this comment.
The data in the analyticsData JavaScript object is duplicated in two places - once in analytics.js using Razor syntax to generate JavaScript, and again in the inline script block in Analytics.cshtml. This creates unnecessary code duplication and maintenance overhead. The inline script in Analytics.cshtml should reference the external analytics.js file instead of redefining the same data and functions.
| } | ||
| catch (Exception ex) | ||
| { | ||
| ViewBag.ErrorMessage = $"Error retrieving analytics data: {ex.Message}"; |
There was a problem hiding this comment.
The error message displayed to users includes the raw exception message which may expose sensitive technical details or stack traces. For user-facing error messages, consider showing a generic message and logging the detailed exception information separately using proper logging infrastructure.
| @@ -18,6 +27,25 @@ cfg: { // Application Insights Configuration | |||
| instrumentationKey: "c4b69618-5135-49c7-a75d-215953ddde9b" | |||
There was a problem hiding this comment.
The hardcoded instrumentation key 'c4b69618-5135-49c7-a75d-215953ddde9b' in the Application Insights JavaScript snippet should not be committed to source control. This key could be a production key that grants access to your telemetry data. Consider reading this from configuration and injecting it via Razor, or ensure this is a development-only key.
| instrumentationKey: "c4b69618-5135-49c7-a75d-215953ddde9b" | |
| instrumentationKey: "@appInsightsKey" |
| <text> { url: '@Html.Raw(System.Text.Json.JsonSerializer.Serialize(pv.PageUrl))', count: @pv.ViewCount }@comma | ||
| </text> | ||
| } | ||
| } | ||
| ], | ||
| pdfDownloads: [ | ||
| @if (Model.PdfDownloads.Any()) | ||
| { | ||
| for (int i = 0; i < Model.PdfDownloads.Count(); i++) | ||
| { | ||
| var dl = Model.PdfDownloads.ElementAt(i); | ||
| var comma = i < Model.PdfDownloads.Count() - 1 ? "," : ""; | ||
| <text> { name: '@Html.Raw(System.Text.Json.JsonSerializer.Serialize(dl.FileName))', count: @dl.DownloadCount }@comma | ||
| </text> | ||
| } | ||
| } | ||
| ], | ||
| searchTerms: [ | ||
| @if (Model.SearchTerms.Any()) | ||
| { | ||
| for (int i = 0; i < Model.SearchTerms.Count(); i++) | ||
| { | ||
| var st = Model.SearchTerms.ElementAt(i); | ||
| var comma = i < Model.SearchTerms.Count() - 1 ? "," : ""; | ||
| <text> { type: '@Html.Raw(System.Text.Json.JsonSerializer.Serialize(st.SearchType))', term: '@Html.Raw(System.Text.Json.JsonSerializer.Serialize(st.SearchTerm))', count: @st.SearchCount }@comma | ||
| </text> | ||
| } |
There was a problem hiding this comment.
The data serialization using System.Text.Json.JsonSerializer.Serialize mixed with Html.Raw() could be simplified. While this approach works to safely escape special characters in strings for JavaScript, it's creating nested encoding. Consider using a more straightforward approach like @Json.Serialize() or creating a properly typed JavaScript object from server-side data.
| private readonly TelemetryClient? _telemetryClient; | ||
| private readonly string? _workspaceId; | ||
| private readonly bool _isAzureConfigured; | ||
| private readonly IConfiguration _configuration; | ||
|
|
||
| public AnalyticsService(IConfiguration configuration, TelemetryClient? telemetryClient = null) |
There was a problem hiding this comment.
The AnalyticsService is registered with AddScoped lifetime, but in the constructor it only accepts IConfiguration and an optional TelemetryClient. If TelemetryClient needs to be injected, it should be a required parameter rather than optional with a default value of null. The optional parameter pattern can lead to confusion about whether the dependency is truly optional or if it's just a workaround for testing scenarios.
| private readonly TelemetryClient? _telemetryClient; | |
| private readonly string? _workspaceId; | |
| private readonly bool _isAzureConfigured; | |
| private readonly IConfiguration _configuration; | |
| public AnalyticsService(IConfiguration configuration, TelemetryClient? telemetryClient = null) | |
| private readonly TelemetryClient _telemetryClient; | |
| private readonly string? _workspaceId; | |
| private readonly bool _isAzureConfigured; | |
| private readonly IConfiguration _configuration; | |
| public AnalyticsService(IConfiguration configuration, TelemetryClient telemetryClient) |
| @for (int y = DateTime.Now.Year; y >= DateTime.Now.Year - 1; y--) | ||
| { | ||
| <option value="@y" selected="@(Model.SelectedYear == y)">@y</option> | ||
| } | ||
| </select> |
There was a problem hiding this comment.
The year dropdown only shows the current year and one year back (line 137: DateTime.Now.Year - 1). This is very limiting for analytics purposes where you may want to view historical data from previous years. Consider expanding this range to show at least 5-10 years of history, or all years since the application was deployed.
| catch | ||
| { |
There was a problem hiding this comment.
Empty catch blocks are a poor practice as they silently swallow exceptions and hide potential issues. If URL parsing fails in ExtractFileNameFromUrl, the method returns the original URL, which may not be the desired behavior. Consider at minimum logging the exception, or returning a more descriptive fallback value like "Invalid URL" or "Parse Error".
| catch | |
| { | |
| catch (Exception ex) | |
| { | |
| Console.WriteLine($"Error extracting file name from URL '{url}': {ex.Message}"); |
| var query = $@" | ||
| pageViews | ||
| | where timestamp >= datetime({startDate:yyyy-MM-ddTHH:mm:ssZ}) | ||
| | where timestamp <= datetime({endDate:yyyy-MM-ddTHH:mm:ssZ}) | ||
| | where isempty(operation_SyntheticSource) | ||
| | where client_Type == 'Browser' or client_Type == 'PC' | ||
| | where name !has '/Details/' and name !endswith '/Details' | ||
| | where name !has '/Edit/' and name !endswith '/Edit' | ||
| | where name !has '/Delete/' and name !endswith '/Delete' | ||
| | summarize ViewCount = count() by name | ||
| | order by ViewCount desc | ||
| | limit 50"; |
There was a problem hiding this comment.
The dates are formatted directly in KQL queries using string interpolation (lines 202-203, 253-254, 306-307). This approach is vulnerable to issues if the DateTime format changes or if timezones are not properly handled. While this isn't a SQL injection risk (KQL doesn't have that vulnerability in this context), using parameterized queries or the KQL datetime() function more safely would be more robust. The current format assumes the DateTime is formatted consistently, which could fail with different cultures or timezone settings.
| startDate = endDate.Value.AddDays(-30); | ||
| } | ||
| // Ensure end date includes the full day | ||
| else if (endDate.HasValue) |
There was a problem hiding this comment.
Condition is always true because of access to property HasValue.
| else if (endDate.HasValue) | |
| else |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- Refactored AnalyticsService for clarity, modularity, and better error handling; separated Azure and local data logic. - Moved analytics data preparation from Razor view to a JS object (`analyticsData`) and delegated clipboard export logic to a new external JS file (`admin-analytics.js`). - Cleaned up analytics.js to only handle report formatting and export, expecting data from the global object. - Improves separation of concerns, maintainability, and testability.
Always include and properly format the Application Insights script in _Layout.cshtml. Remove the local development mock; production instrumentation key is now always used. Improves maintainability and clarity of configuration.
Replaced all Console.WriteLine statements with structured logging using ILogger<AnalyticsService> via dependency injection. Improved logging for initialization, error handling, and local development scenarios to enhance observability and align with .NET best practices.
Closes #438
Summary
An admin dashboard has been created for the monthly stats they ask for via Email each month. A Copy stats button is added to the top to copy the data as plain text to make it easy to paste into an email. Analytic data is hardcoded for the development environment. KQL queries for Application Insights data were verified in Azure use the Log analytics in Application Insights. Page Views were being tracked twice, once on the server and once on the client, the server side page tracking has been removed in favor of the JS tracking. We are not tracking bot traffic at the moment so this was a simple solution to implement
Copilot Summary
This pull request introduces a new Application Insights-powered analytics dashboard for the admin area, enabling both local development with sample data and production use with real Azure telemetry. It adds new models, controller logic, configuration documentation, and service registration, while removing the old page view tracking filter. The changes provide a robust, configurable analytics solution that supports filtering by date, month, and year, and gracefully handles both local and Azure environments.
Analytics Dashboard Implementation
AdminControllerwith anAnalyticsaction to display analytics data (page views, PDF downloads, search terms) for a selected date range or month/year, including error handling and configuration guidance.AnalyticsViewModeland supporting data models (PageViewData,DownloadData,SearchTermData) to structure analytics data for the dashboard view.Application Insights Configuration
ApplicationInsights-Configuration.mddocumentation, detailing local development setup, production configuration, and troubleshooting for Application Insights and the analytics dashboard.AnalyticsServicefor dependency injection inProgram.csand updated Application Insights telemetry configuration to support developer mode and adaptive sampling for local debugging.Azure.Identity,Azure.Monitor.Query) to support querying telemetry data from Azure Application Insights and Log Analytics.Codebase Cleanup
ApplicationInsightsPageViewTrackerfilter, as analytics tracking is now handled by the new dashboard and service.Project Structure
Filtersfolder to the project structure in anticipation of future filter implementations.