Skip to content

Commit

Permalink
Merge pull request #379 from nventive/dev/jpl/default-analytics
Browse files Browse the repository at this point in the history
feat: Add default analytics hooks.
  • Loading branch information
jeanplevesque authored Dec 5, 2023
2 parents ab943dd + b430e6f commit 3933efd
Show file tree
Hide file tree
Showing 13 changed files with 232 additions and 19 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ 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 and command invocations).
- Renamed the `AnalyticsDataLoaderStrategy` to `MonitoringDataLoaderStrategy`. (The same renaming was applied to related methods and classes).

## 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.
Expand Down
5 changes: 5 additions & 0 deletions doc/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions doc/DefaultAnalytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# 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.).

> 💡 The default events are meant to be a starting point for your application's analytics. Because they are automatic, they are more generic than custom events. If you want to track more specific events, you can adjust this recipe by adding new members to the `IAnalyticsSink` interface (or changing the existing ones) to better suit your needs.
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.

## 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 command invocations.

Command executions are typically associated with button presses and gestures.
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Adds the analytics services to the <see cref="IServiceCollection"/>.
/// </summary>
/// <param name="services">The service collection.</param>
public static IServiceCollection AddAnalytics(this IServiceCollection services)
{
return services.AddSingleton<IAnalyticsSink, AnalyticsSink>();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +42,7 @@ private static IServiceCollection AddDynamicCommands(this IServiceCollection ser
new DynamicCommandBuilderFactory(c => c
.CatchErrors(s.GetRequiredService<IDynamicCommandErrorHandler>())
.WithLogs(s.GetRequiredService<ILogger<IDynamicCommand>>())
.WithStrategy(new AnalyticsCommandStrategy(s.GetRequiredService<IAnalyticsSink>(), c.ViewModel))
.WithStrategy(new RaiseCanExecuteOnDispatcherCommandStrategy(c.ViewModel))
.DisableWhileExecuting()
.OnBackgroundThread()
Expand All @@ -61,9 +63,9 @@ private static IServiceCollection AddDataLoaders(this IServiceCollection service
return new DataLoaderBuilderFactory(b => b
.OnBackgroundThread()
.WithEmptySelector(GetIsEmpty)
.WithAnalytics(
onSuccess: async (ct, request, value) => { /* Some analytics */ },
onError: async (ct, request, error) => { /* Somme analytics */ }
.WithMonitoring(
onSuccess: async (ct, request, value) => { /* Some monitoring logic */ },
onError: async (ct, request, error) => { /* Some monitoring logic */ }
)
.WithLoggedErrors(s.GetRequiredService<ILogger<IDataLoader>>())
);
Expand Down
16 changes: 12 additions & 4 deletions src/app/ApplicationTemplate.Presentation/CoreStartup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ protected override IHostBuilder InitializeServices(IHostBuilder hostBuilder, str
.AddLocalization()
.AddReviewServices()
.AddAppServices()
.AddAnalytics()
);
}

Expand All @@ -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<IReviewService>().TrackApplicationLaunched(CancellationToken.None);

NotifyUserOnSessionExpired(services);

services.GetRequiredService<DiagnosticsCountersService>().Start();

await ExecuteInitialNavigation(CancellationToken.None, services);
}
}
Expand Down Expand Up @@ -119,6 +117,16 @@ public static async Task ExecuteInitialNavigation(CancellationToken ct, IService
services.GetRequiredService<IExtendedSplashscreenController>().Dismiss();
}

private void StartAutomaticAnalyticsCollection(IServiceProvider services)
{
var analyticsSink = services.GetRequiredService<IAnalyticsSink>();
var sectionsNavigator = services.GetRequiredService<ISectionsNavigator>();
sectionsNavigator
.ObserveCurrentState()
.Subscribe(analyticsSink.TrackNavigation)
.DisposeWith(Disposables);
}

private void NotifyUserOnSessionExpired(IServiceProvider services)
{
var authenticationService = services.GetRequiredService<IAuthenticationService>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#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 sealed class AnalyticsSink : IAnalyticsSink
{
private readonly ILogger<AnalyticsSink> _logger;
private INavigableViewModel? _lastViewModel;

public AnalyticsSink(ILogger<AnalyticsSink> 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 TrackCommand(string commandName, object? commandParameter, WeakReference<IViewModel>? 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("Invoked command '{CommandName}' from ViewModel '{ViewModelName}'.", commandName, vm.Name);
}
else
{
_logger.LogInformation("Invoked command '{CommandName}'.", commandName);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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;

/// <summary>
/// This <see cref="IDynamicCommandStrategy"/> tracks the success and failure of a command for analytics purposes.
/// </summary>
public sealed class AnalyticsCommandStrategy : DelegatingCommandStrategy
{
private readonly IAnalyticsSink _analyticsSink;
private readonly WeakReference<IViewModel> _viewModel;

public AnalyticsCommandStrategy(IAnalyticsSink analyticsSink, IViewModel viewModel)
{
_analyticsSink = analyticsSink;
_viewModel = new WeakReference<IViewModel>(viewModel);
}

public override async Task Execute(CancellationToken ct, object parameter, IDynamicCommand command)
{
_analyticsSink.TrackCommand(command.Name, parameter, _viewModel);

await base.Execute(ct, parameter, command);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#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;

/// <summary>
/// This service collects raw analytics data from the application and processes it to send to an analytics provider (such as AppCenter, Firebase, Segment, etc.).
/// </summary>
public interface IAnalyticsSink
{
/// <summary>
/// Tracks a navigation event from which to derive page views.
/// </summary>
/// <param name="navigatorState">The state of the navigator.</param>
void TrackNavigation(SectionsNavigatorState navigatorState);

/// <summary>
/// Tracks a command execution initiation.
/// </summary>
/// <param name="commandName">The name of the command.</param>
/// <param name="commandParameter">The optional command parameter.</param>
/// <param name="viewModel">An optional weak reference to the ViewModel owning the command.</param>
void TrackCommand(string commandName, object? commandParameter, WeakReference<IViewModel>? viewModel);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
namespace Chinook.DataLoader;

/// <summary>
/// This class is a <see cref="DelegatingDataLoaderStrategy"/> that offers callbacks on success and on error, ideal for analytics.
/// This class demontrates how easy it is to extend the DataLoader recipe.
/// This class is a <see cref="DelegatingDataLoaderStrategy"/> that offers callbacks on success and on error, ideal for monitoring.
/// This class demonstrates how easy it is to extend the DataLoader recipe.
/// </summary>
public class AnalyticsDataLoaderStrategy : DelegatingDataLoaderStrategy
public class MonitoringDataLoaderStrategy : DelegatingDataLoaderStrategy
{
private readonly ActionAsync<IDataLoaderRequest, object> _onSuccess;
private readonly ActionAsync<IDataLoaderRequest, Exception> _onError;

public AnalyticsDataLoaderStrategy(ActionAsync<IDataLoaderRequest, object> onSuccess, ActionAsync<IDataLoaderRequest, Exception> onError)
public MonitoringDataLoaderStrategy(ActionAsync<IDataLoaderRequest, object> onSuccess, ActionAsync<IDataLoaderRequest, Exception> onError)
{
_onSuccess = onSuccess;
_onError = onError;
Expand All @@ -41,19 +41,19 @@ public override async Task<object> Load(CancellationToken ct, IDataLoaderRequest
}
}

public static class AnalyticsDataLoaderStrategyExtensions
public static class MonitoringDataLoaderStrategyExtensions
{
/// <summary>
/// Adds a <see cref="AnalyticsDataLoaderStrategy"/> to this builder.
/// Adds a <see cref="MonitoringDataLoaderStrategy"/> to this builder.
/// </summary>
/// <typeparam name="TBuilder">The type of the builder.</typeparam>
/// <param name="builder">The builder.</param>
/// <param name="onSuccess">The callback when the strategy loads successfully.</param>
/// <param name="onError">The callback when the strategy fails to load.</param>
/// <returns>The original builder.</returns>
public static TBuilder WithAnalytics<TBuilder>(this TBuilder builder, ActionAsync<IDataLoaderRequest, object> onSuccess, ActionAsync<IDataLoaderRequest, Exception> onError)
public static TBuilder WithMonitoring<TBuilder>(this TBuilder builder, ActionAsync<IDataLoaderRequest, object> onSuccess, ActionAsync<IDataLoaderRequest, Exception> onError)
where TBuilder : IDataLoaderBuilder
{
return builder.WithStrategy(new AnalyticsDataLoaderStrategy(onSuccess, onError));
return builder.WithStrategy(new MonitoringDataLoaderStrategy(onSuccess, onError));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(ObserveSelectedIndex(), initialValue: 0);
Expand All @@ -56,7 +56,7 @@ private IObservable<string> ObserveMenuState() =>
.ObserveCurrentState()
.Select(state =>
{
var vmType = state.GetViewModelType();
var vmType = state.GetCurrentOrNextViewModelType();
return GetMenuState(vmType);
})
.DistinctUntilChanged()
Expand Down
2 changes: 1 addition & 1 deletion src/app/ApplicationTemplate.Shared.Views/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit 3933efd

Please sign in to comment.