diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f20a318..43a119f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) Prefix your items with `(Template)` if the change is about the template and not the resulting application. +## 2.2.X +- Added hooks for default analytics (page views, content loads, and command executions). + ## 2.1.X - Install `GooseAnalyzers` to enable the `SA1600` rule with its scope limited to interfaces and improve xml documentation. - Replace local `DispatcherQueue` extension methods with the ones from the WinUI and Uno.WinUI Community Toolkit. diff --git a/doc/Architecture.md b/doc/Architecture.md index f8d1fffdb..5c2a0b321 100644 --- a/doc/Architecture.md +++ b/doc/Architecture.md @@ -176,6 +176,11 @@ This application uses [FluentValidation](https://www.nuget.org/packages/FluentVa See [Validation.md](Validation.md) for more details. +### Analytics +This application has a built-in analytics base that can be used to track events and errors with potentially any analytics service (e.g. AppCenter, Firebase, Segment, etc.). This base is built around the [IAnalyticsSink](../src/app/ApplicationTemplate.Presentation/Framework/Analytics/IAnalyticsSink.cs) interface. + +See [DefaultAnalytics.md](DefaultAnalytics.md) for more details. + ## View ### UI Framework diff --git a/doc/DefaultAnalytics.md b/doc/DefaultAnalytics.md new file mode 100644 index 000000000..35697dd8b --- /dev/null +++ b/doc/DefaultAnalytics.md @@ -0,0 +1,26 @@ +# Default Analytics + +This application comes with a few default tracked events. +They can be found in the [IAnalyticsSink](../src/app/ApplicationTemplate.Presentation/Framework/Analytics/IAnalyticsSink.cs) interface. +The idea is that you would change the implementation of this interface to send the events to an analytics service (such as AppCenter, Firebase, Segment, etc.). + +Here is a list of the default events: + +## Page Views +This is based on the changes of state from the `ISectionsNavigator`. + +The `ISectionsNavigator` controls the navigation of the application. It's state can be observed and this is leveraged to detect the page views. + +## Content Loads +This is based on the default builder of the `IDataLoaderFactory`. + +The `IDataLoaderBuilder` allows to customize the default behavior or all DataLoaders. This is leveraged to inject analytics on successful and failed loads. + +Content loads are different than page views because a single page view can be associated with multiple content loads. + +## Command Executions +This is based on the default builder of the `IDynamicCommandBuilderFactory`. + +The `IDynamicCommandBuilder` allows to customize the default behavior or all DynamicCommands. This is leveraged to inject analytics on successful and failed command executions. + +Command executions are typically associated with button presses and gestures. \ No newline at end of file diff --git a/src/app/ApplicationTemplate.Presentation/Configuration/AnalyticsConfiguration.cs b/src/app/ApplicationTemplate.Presentation/Configuration/AnalyticsConfiguration.cs new file mode 100644 index 000000000..91fd809d4 --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/Configuration/AnalyticsConfiguration.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace ApplicationTemplate.Presentation; + +public static class AnalyticsConfiguration +{ + /// + /// Adds the analytics services to the . + /// + /// The service collection. + public static IServiceCollection AddAnalytics(this IServiceCollection services) + { + return services.AddSingleton(); + } +} diff --git a/src/app/ApplicationTemplate.Presentation/Configuration/ViewModelConfiguration.cs b/src/app/ApplicationTemplate.Presentation/Configuration/ViewModelConfiguration.cs index f6793abd2..fb01f5aab 100644 --- a/src/app/ApplicationTemplate.Presentation/Configuration/ViewModelConfiguration.cs +++ b/src/app/ApplicationTemplate.Presentation/Configuration/ViewModelConfiguration.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Linq; +using ApplicationTemplate.Presentation; using Chinook.DataLoader; using Chinook.DynamicMvvm; using Chinook.DynamicMvvm.Implementations; @@ -41,6 +42,7 @@ private static IServiceCollection AddDynamicCommands(this IServiceCollection ser new DynamicCommandBuilderFactory(c => c .CatchErrors(s.GetRequiredService()) .WithLogs(s.GetRequiredService>()) + .WithStrategy(new AnalyticsCommandStrategy(s.GetRequiredService(), c.ViewModel)) .WithStrategy(new RaiseCanExecuteOnDispatcherCommandStrategy(c.ViewModel)) .DisableWhileExecuting() .OnBackgroundThread() @@ -62,8 +64,8 @@ private static IServiceCollection AddDataLoaders(this IServiceCollection service .OnBackgroundThread() .WithEmptySelector(GetIsEmpty) .WithAnalytics( - onSuccess: async (ct, request, value) => { /* Some analytics */ }, - onError: async (ct, request, error) => { /* Somme analytics */ } + onSuccess: async (ct, request, value) => s.GetRequiredService().TrackDataLoaderSuccess(request, value), + onError: async (ct, request, error) => s.GetRequiredService().TrackDataLoaderFailure(request, error) ) .WithLoggedErrors(s.GetRequiredService>()) ); diff --git a/src/app/ApplicationTemplate.Presentation/CoreStartup.cs b/src/app/ApplicationTemplate.Presentation/CoreStartup.cs index 813dd3128..ac5e9a0fe 100644 --- a/src/app/ApplicationTemplate.Presentation/CoreStartup.cs +++ b/src/app/ApplicationTemplate.Presentation/CoreStartup.cs @@ -49,6 +49,7 @@ protected override IHostBuilder InitializeServices(IHostBuilder hostBuilder, str .AddLocalization() .AddReviewServices() .AddAppServices() + .AddAnalytics() ); } @@ -70,13 +71,10 @@ protected override async Task StartServices(IServiceProvider services, bool isFi if (isFirstStart) { // TODO: Start your core services and customize the initial navigation logic here. - + StartAutomaticAnalyticsCollection(services); await services.GetRequiredService().TrackApplicationLaunched(CancellationToken.None); - NotifyUserOnSessionExpired(services); - services.GetRequiredService().Start(); - await ExecuteInitialNavigation(CancellationToken.None, services); } } @@ -119,6 +117,16 @@ public static async Task ExecuteInitialNavigation(CancellationToken ct, IService services.GetRequiredService().Dismiss(); } + private void StartAutomaticAnalyticsCollection(IServiceProvider services) + { + var analyticsSink = services.GetRequiredService(); + var sectionsNavigator = services.GetRequiredService(); + sectionsNavigator + .ObserveCurrentState() + .Subscribe(analyticsSink.TrackNavigation) + .DisposeWith(Disposables); + } + private void NotifyUserOnSessionExpired(IServiceProvider services) { var authenticationService = services.GetRequiredService(); diff --git a/src/app/ApplicationTemplate.Presentation/Framework/Analytics/AnalytcsSink.cs b/src/app/ApplicationTemplate.Presentation/Framework/Analytics/AnalytcsSink.cs new file mode 100644 index 000000000..9c30aaa82 --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/Framework/Analytics/AnalytcsSink.cs @@ -0,0 +1,146 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Xml.Linq; +using Chinook.DataLoader; +using Chinook.DynamicMvvm; +using Chinook.SectionsNavigation; +using Chinook.StackNavigation; +using Microsoft.Extensions.Logging; + +namespace ApplicationTemplate.Presentation; + +public class AnalyticsSink : IAnalyticsSink +{ + private readonly ILogger _logger; + private INavigableViewModel? _lastViewModel; + + public AnalyticsSink(ILogger logger) + { + _logger = logger; + } + + public void TrackNavigation(SectionsNavigatorState navigatorState) + { + if (navigatorState.LastRequestState != NavigatorRequestState.Processed) + { + // Skip the requests that are still processing of that failed to process. + return; + } + + // Get the actual ViewModel instance. + // This allows to track based on instances and not types (because there are scenarios where you can open the same page multiple times with different parameters). + // Having the instance also allows casting into more specific types to get more information, such as navigation parameters, that could be relevant for analytics. + var viewModel = navigatorState.GetActiveStackNavigator().State.Stack.LastOrDefault()?.ViewModel; + if (viewModel is null || _lastViewModel == viewModel) + { + return; + } + + // Gather analytics data. + var pageName = viewModel.GetType().Name.Replace("ViewModel", string.Empty, StringComparison.OrdinalIgnoreCase); + var isInModal = navigatorState.ActiveModal != null; + var sectionName = navigatorState.ActiveSection.Name; + + // Send the analytics event. + SendPageView(pageName, isInModal, sectionName); + + // Capture the last ViewModel instance to avoid duplicate events in the future. + _lastViewModel = viewModel; + } + + private void SendPageView(string pageName, bool isInModal, string sectionName) + { + // TODO: Implement page views using a real analytics provider. + if (!_logger.IsEnabled(LogLevel.Information)) + { + return; + } + + if (isInModal) + { + _logger.LogInformation("Viewed page '{PageName}' in modal.", pageName); + } + else + { + _logger.LogInformation("Viewed page '{PageName}' in section '{SectionName}'.", pageName, sectionName); + } + } + + public void TrackDataLoaderSuccess(IDataLoaderRequest request, object value) + { + // TODO: Implement data loaded events using a real analytics provider. + if (!_logger.IsEnabled(LogLevel.Information)) + { + return; + } + + var dataLoaderName = request.Context.GetDataLoaderName(); + if (request.Context.TryGetValue("IsForceRefreshing", out var isForceRefreshing) && isForceRefreshing is true) + { + _logger.LogInformation("Refreshed content '{DataLoaderName}'.", dataLoaderName); + } + else + { + _logger.LogInformation("Loaded content '{DataLoaderName}'.", dataLoaderName); + } + } + + public void TrackDataLoaderFailure(IDataLoaderRequest request, Exception exception) + { + // TODO: Implement data loaded events using a real analytics provider. + if (!_logger.IsEnabled(LogLevel.Information)) + { + return; + } + + var dataLoaderName = request.Context.GetDataLoaderName(); + if (request.Context.TryGetValue("IsForceRefreshing", out var isForceRefreshing) && isForceRefreshing is true) + { + _logger.LogInformation("Failed to refresh content '{DataLoaderName}'.", dataLoaderName); + } + else + { + _logger.LogInformation("Failed to load content '{DataLoaderName}'.", dataLoaderName); + } + } + + public void TrackCommandSuccess(string commandName, object? commandParameter, WeakReference? viewModel) + { + // TODO: Implement command execution events using a real analytics provider. + if (!_logger.IsEnabled(LogLevel.Information)) + { + return; + } + + if (viewModel?.TryGetTarget(out var vm) ?? false) + { + _logger.LogInformation("Executed command '{CommandName}' from ViewModel '{ViewModelName}'.", commandName, vm.Name); + } + else + { + _logger.LogInformation("Executed command '{CommandName}'.", commandName); + } + } + + public void TrackCommandFailure(string commandName, object? commandParameter, WeakReference? viewModel, Exception exception) + { + // TODO: Implement command execution events using a real analytics provider. + if (!_logger.IsEnabled(LogLevel.Information)) + { + return; + } + + if (viewModel?.TryGetTarget(out var vm) ?? false) + { + _logger.LogInformation("Failed to execute command '{CommandName}' from ViewModel '{ViewModelName}'.", commandName, vm.Name); + } + else + { + _logger.LogInformation("Failed to execute command '{CommandName}'.", commandName); + } + } +} diff --git a/src/app/ApplicationTemplate.Presentation/Framework/Analytics/AnalyticsCommandStrategy.cs b/src/app/ApplicationTemplate.Presentation/Framework/Analytics/AnalyticsCommandStrategy.cs new file mode 100644 index 000000000..d2714e22e --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/Framework/Analytics/AnalyticsCommandStrategy.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Chinook.DynamicMvvm; + +namespace ApplicationTemplate.Presentation; + +/// +/// This tracks the success and failure of a command for analytics purposes. +/// +public class AnalyticsCommandStrategy : DelegatingCommandStrategy +{ + private readonly IAnalyticsSink _analyticsSink; + private readonly WeakReference _viewModel; + + public AnalyticsCommandStrategy(IAnalyticsSink analyticsSink, IViewModel viewModel) + { + _analyticsSink = analyticsSink; + _viewModel = new WeakReference(viewModel); + } + + public override async Task Execute(CancellationToken ct, object parameter, IDynamicCommand command) + { + try + { + await base.Execute(ct, parameter, command); + + _analyticsSink.TrackCommandSuccess(command.Name, parameter, _viewModel); + } + catch (Exception e) + { + _analyticsSink.TrackCommandFailure(command.Name, parameter, _viewModel, e); + + throw; + } + } +} diff --git a/src/app/ApplicationTemplate.Presentation/Framework/Analytics/IAnalyticsSink.cs b/src/app/ApplicationTemplate.Presentation/Framework/Analytics/IAnalyticsSink.cs new file mode 100644 index 000000000..7e0fa395f --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/Framework/Analytics/IAnalyticsSink.cs @@ -0,0 +1,54 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Chinook.DataLoader; +using Chinook.DynamicMvvm; +using Chinook.SectionsNavigation; + +namespace ApplicationTemplate.Presentation; + +/// +/// This service collects raw analytics data from the application and processes it to send to an analytics provider (such as AppCenter, Firebase, Segment, etc.). +/// +public interface IAnalyticsSink +{ + /// + /// Tracks a navigation event from which to derive page views. + /// + /// The state of the navigator. + void TrackNavigation(SectionsNavigatorState navigatorState); + + /// + /// Tracks a successful data loader request. + /// + /// The request that was successful. + /// The value that was successfully loaded. + void TrackDataLoaderSuccess(IDataLoaderRequest request, object value); + + /// + /// Tracks a failed data loader request. + /// + /// The request that failed. + /// The exception produced. + void TrackDataLoaderFailure(IDataLoaderRequest request, Exception exception); + + /// + /// Tracks a successful command execution. + /// + /// The name of the command. + /// The optional command parameter. + /// An optional weak reference to the ViewModel owning the command. + void TrackCommandSuccess(string commandName, object? commandParameter, WeakReference? viewModel); + + /// + /// Tracks a failed command execution. + /// + /// The name of the command. + /// The optional command parameter. + /// An optional weak reference to the ViewModel owning the command. + /// The exception produced. + void TrackCommandFailure(string commandName, object? commandParameter, WeakReference? viewModel, Exception exception); +} diff --git a/src/app/ApplicationTemplate.Presentation/ViewModels/Extensions/SectionsNavigatorState.Extensions.cs b/src/app/ApplicationTemplate.Presentation/ViewModels/Extensions/SectionsNavigatorState.Extensions.cs index 0faf5d350..2095e754f 100644 --- a/src/app/ApplicationTemplate.Presentation/ViewModels/Extensions/SectionsNavigatorState.Extensions.cs +++ b/src/app/ApplicationTemplate.Presentation/ViewModels/Extensions/SectionsNavigatorState.Extensions.cs @@ -5,7 +5,7 @@ namespace Chinook.SectionsNavigation; public static class SectionsNavigatorStateExtensions { - public static Type GetViewModelType(this SectionsNavigatorState sectionsNavigatorState) + public static Type GetCurrentOrNextViewModelType(this SectionsNavigatorState sectionsNavigatorState) { switch (sectionsNavigatorState.LastRequestState) { diff --git a/src/app/ApplicationTemplate.Presentation/ViewModels/MenuViewModel.cs b/src/app/ApplicationTemplate.Presentation/ViewModels/MenuViewModel.cs index ded61772a..396d3bde7 100644 --- a/src/app/ApplicationTemplate.Presentation/ViewModels/MenuViewModel.cs +++ b/src/app/ApplicationTemplate.Presentation/ViewModels/MenuViewModel.cs @@ -34,7 +34,7 @@ public enum Section public string MenuState => this.GetFromObservable( ObserveMenuState(), - initialValue: GetMenuState(_sectionsNavigator.State.GetViewModelType()) + initialValue: GetMenuState(_sectionsNavigator.State.GetCurrentOrNextViewModelType()) ); public int SelectedIndex => this.GetFromObservable(ObserveSelectedIndex(), initialValue: 0); @@ -56,7 +56,7 @@ private IObservable ObserveMenuState() => .ObserveCurrentState() .Select(state => { - var vmType = state.GetViewModelType(); + var vmType = state.GetCurrentOrNextViewModelType(); return GetMenuState(vmType); }) .DistinctUntilChanged() diff --git a/src/app/ApplicationTemplate.Shared.Views/Startup.cs b/src/app/ApplicationTemplate.Shared.Views/Startup.cs index 570df1d06..8b35b52c9 100644 --- a/src/app/ApplicationTemplate.Shared.Views/Startup.cs +++ b/src/app/ApplicationTemplate.Shared.Views/Startup.cs @@ -216,7 +216,7 @@ private void SetStatusBarColor(IServiceProvider services) .ObserveOn(dispatcher) .Subscribe(onNext: state => { - var currentVmType = state.CurrentState.GetViewModelType(); + var currentVmType = state.CurrentState.GetCurrentOrNextViewModelType(); // We set the default status bar color to white. var statusBarColor = Microsoft.UI.Colors.White;