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;