From b430e6fb306b093dfe9a3943e126f84f7b691736 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Levesque Date: Wed, 29 Nov 2023 10:17:00 -0500 Subject: [PATCH 1/3] feat: Add default analytics hooks. --- CHANGELOG.md | 4 + doc/Architecture.md | 5 ++ doc/DefaultAnalytics.md | 21 +++++ .../Configuration/AnalyticsConfiguration.cs | 21 +++++ .../Configuration/ViewModelConfiguration.cs | 8 +- .../CoreStartup.cs | 16 +++- .../Framework/Analytics/AnalytcsSink.cs | 90 +++++++++++++++++++ .../Analytics/AnalyticsCommandStrategy.cs | 31 +++++++ .../Framework/Analytics/IAnalyticsSink.cs | 31 +++++++ ...egy.cs => MonitoringDataLoaderStrategy.cs} | 16 ++-- .../SectionsNavigatorState.Extensions.cs | 2 +- .../ViewModels/MenuViewModel.cs | 4 +- .../Startup.cs | 2 +- 13 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 doc/DefaultAnalytics.md create mode 100644 src/app/ApplicationTemplate.Presentation/Configuration/AnalyticsConfiguration.cs create mode 100644 src/app/ApplicationTemplate.Presentation/Framework/Analytics/AnalytcsSink.cs create mode 100644 src/app/ApplicationTemplate.Presentation/Framework/Analytics/AnalyticsCommandStrategy.cs create mode 100644 src/app/ApplicationTemplate.Presentation/Framework/Analytics/IAnalyticsSink.cs rename src/app/ApplicationTemplate.Presentation/Framework/DataLoader/{AnalyticsDataLoaderStrategy.cs => MonitoringDataLoaderStrategy.cs} (61%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94f20a318..371fe6b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. 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..ae52ae3f1 --- /dev/null +++ b/doc/DefaultAnalytics.md @@ -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. \ 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..a6bb9eb70 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() @@ -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>()) ); 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..bd4da02b4 --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/Framework/Analytics/AnalytcsSink.cs @@ -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 _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 TrackCommand(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("Invoked command '{CommandName}' from ViewModel '{ViewModelName}'.", commandName, vm.Name); + } + else + { + _logger.LogInformation("Invoked 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..93627d15f --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/Framework/Analytics/AnalyticsCommandStrategy.cs @@ -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; + +/// +/// This tracks the success and failure of a command for analytics purposes. +/// +public sealed 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) + { + _analyticsSink.TrackCommand(command.Name, parameter, _viewModel); + + await base.Execute(ct, parameter, command); + } +} 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..3a229b67f --- /dev/null +++ b/src/app/ApplicationTemplate.Presentation/Framework/Analytics/IAnalyticsSink.cs @@ -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; + +/// +/// 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 command execution initiation. + /// + /// The name of the command. + /// The optional command parameter. + /// An optional weak reference to the ViewModel owning the command. + void TrackCommand(string commandName, object? commandParameter, WeakReference? viewModel); +} diff --git a/src/app/ApplicationTemplate.Presentation/Framework/DataLoader/AnalyticsDataLoaderStrategy.cs b/src/app/ApplicationTemplate.Presentation/Framework/DataLoader/MonitoringDataLoaderStrategy.cs similarity index 61% rename from src/app/ApplicationTemplate.Presentation/Framework/DataLoader/AnalyticsDataLoaderStrategy.cs rename to src/app/ApplicationTemplate.Presentation/Framework/DataLoader/MonitoringDataLoaderStrategy.cs index 10b98815a..0426f00d5 100644 --- a/src/app/ApplicationTemplate.Presentation/Framework/DataLoader/AnalyticsDataLoaderStrategy.cs +++ b/src/app/ApplicationTemplate.Presentation/Framework/DataLoader/MonitoringDataLoaderStrategy.cs @@ -8,15 +8,15 @@ namespace Chinook.DataLoader; /// -/// This class is a 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 that offers callbacks on success and on error, ideal for monitoring. +/// This class demonstrates how easy it is to extend the DataLoader recipe. /// -public class AnalyticsDataLoaderStrategy : DelegatingDataLoaderStrategy +public class MonitoringDataLoaderStrategy : DelegatingDataLoaderStrategy { private readonly ActionAsync _onSuccess; private readonly ActionAsync _onError; - public AnalyticsDataLoaderStrategy(ActionAsync onSuccess, ActionAsync onError) + public MonitoringDataLoaderStrategy(ActionAsync onSuccess, ActionAsync onError) { _onSuccess = onSuccess; _onError = onError; @@ -41,19 +41,19 @@ public override async Task Load(CancellationToken ct, IDataLoaderRequest } } -public static class AnalyticsDataLoaderStrategyExtensions +public static class MonitoringDataLoaderStrategyExtensions { /// - /// Adds a to this builder. + /// Adds a to this builder. /// /// The type of the builder. /// The builder. /// The callback when the strategy loads successfully. /// The callback when the strategy fails to load. /// The original builder. - public static TBuilder WithAnalytics(this TBuilder builder, ActionAsync onSuccess, ActionAsync onError) + public static TBuilder WithMonitoring(this TBuilder builder, ActionAsync onSuccess, ActionAsync onError) where TBuilder : IDataLoaderBuilder { - return builder.WithStrategy(new AnalyticsDataLoaderStrategy(onSuccess, onError)); + return builder.WithStrategy(new MonitoringDataLoaderStrategy(onSuccess, onError)); } } 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; From c05aaa8210845733645a8aca4bca718833e5c40e Mon Sep 17 00:00:00 2001 From: Carl Mathieu Date: Thu, 7 Dec 2023 16:42:16 -0500 Subject: [PATCH 2/3] chore: switch to mac MS-Hosted agents --- CHANGELOG.md | 2 +- build/stage-release-appcenter.yml | 2 +- build/stage-release-appstore.yml | 4 +--- build/templates/gitversion.yml | 2 +- build/variables.yml | 4 ---- 5 files changed, 4 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 371fe6b0f..0211d0517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ Prefix your items with `(Template)` if the change is about the template and not - Enable `TreatWarningsAsErrors` for the Access, Business, and Presentation projects. - Update analyzers packages and severity of rules. - Fix crash from ARM base mac on net7.0-iOS. Add `ForceSimulatorX64ArchitectureInIDE` property to mobile head. -- Consolidate build agents to Microsoft-hosted agents. +- Consolidate Windows and macOS build agents to Microsoft-hosted agents. ## 2.0.X - Renamed the classes providing data to use the `Repository` suffix instead of `Endpoint` or `Service`. diff --git a/build/stage-release-appcenter.yml b/build/stage-release-appcenter.yml index b8c4dd457..c0f95ac1f 100644 --- a/build/stage-release-appcenter.yml +++ b/build/stage-release-appcenter.yml @@ -61,7 +61,7 @@ jobs: - deployment: AppCenter_iOS pool: - name: $(macOSPoolName) + vmImage: $(macOSHostedAgentImage) demands: - fastlane variables: diff --git a/build/stage-release-appstore.yml b/build/stage-release-appstore.yml index fc9fe5ab2..d6c91a2bd 100644 --- a/build/stage-release-appstore.yml +++ b/build/stage-release-appstore.yml @@ -6,9 +6,7 @@ jobs: - deployment: AppStore_iOS_${{ parameters.deploymentEnvironment}} pool: - name: $(macOSPoolName) - demands: - - Xamarin.iOS_Version -equals $(XAMARIN_IOS_VERSION) + vmImage: $(macOSHostedAgentImage) environment: ${{ parameters.deploymentEnvironment }} diff --git a/build/templates/gitversion.yml b/build/templates/gitversion.yml index c5df69610..e74073f1a 100644 --- a/build/templates/gitversion.yml +++ b/build/templates/gitversion.yml @@ -2,7 +2,7 @@ steps: - task: gitversion/setup@0 retryCountOnTaskFailure: 3 inputs: - versionSpec: '5.10.1' + versionSpec: '5.12.0' displayName: 'Install GitVersion' #-if false diff --git a/build/variables.yml b/build/variables.yml index ac9fb550f..74ca3a742 100644 --- a/build/variables.yml +++ b/build/variables.yml @@ -74,10 +74,6 @@ variables: # Virtual machine images windowsHostedAgentImage: 'windows-2022' macOSHostedAgentImage: 'macOS-12' - macOSPoolName: 'macOS' - - # Versions to use - XAMARIN_IOS_VERSION: 16.1.1 # Name of the folder where the artefacts will be placed. Variable used in build and release phases. # We make seperate folders so that releases can each download only the folder they need. From f546a4a6530699d2f31b9a5b8e07418a2fc6e9bc Mon Sep 17 00:00:00 2001 From: Kevin Takla Date: Tue, 12 Dec 2023 13:26:40 -0500 Subject: [PATCH 3/3] fix: remove all triggers on the api integration test pipeline --- .azure-pipelines-api-integration-tests.yml | 2 ++ CHANGELOG.md | 1 + 2 files changed, 3 insertions(+) diff --git a/.azure-pipelines-api-integration-tests.yml b/.azure-pipelines-api-integration-tests.yml index b5dccb07c..935eea195 100644 --- a/.azure-pipelines-api-integration-tests.yml +++ b/.azure-pipelines-api-integration-tests.yml @@ -6,6 +6,8 @@ schedules: include: - main +trigger: none + variables: - template: build/variables.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 0211d0517..da94b53ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Prefix your items with `(Template)` if the change is about the template and not ## 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). +- Remove all triggers on the API Integration tests CI. ## 2.1.X - Install `GooseAnalyzers` to enable the `SA1600` rule with its scope limited to interfaces and improve xml documentation.