From fc0d84fa03c9bfc3f7c65237d014c550bf291af7 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Tue, 25 Aug 2026 15:36:58 -0500 Subject: [PATCH 01/36] Implementation (draft) --- Directory.Build.props | 2 +- .../Models/AnalyticsDimensionFilter.cs | 10 + .../Models/AnalyticsEvent.cs | 22 ++ .../Models/AnalyticsEventCriteriaBase.cs | 17 + .../Models/AnalyticsEventSearchCriteria.cs | 14 + .../Models/AnalyticsEventSearchResult.cs | 20 ++ .../Models/AnalyticsEventSummary.cs | 17 + .../Models/AnalyticsEventSummaryCriteria.cs | 5 + .../ModuleConstants.cs | 98 ++++++ .../Services/IAnalyticsService.cs | 14 + .../Models/AnalyticsCompatibilityResult.cs | 12 + .../Models/AnalyticsDataApiSettings.cs | 14 + .../Models/AnalyticsDataQuery.cs | 29 ++ .../Models/AnalyticsReportCompatibility.cs | 16 + .../Services/AnalyticsCompatibilityService.cs | 177 ++++++++++ .../Services/AnalyticsService.cs | 201 +++++++++++ .../Services/AnalyticsSettingsResolver.cs | 47 +++ .../Services/GoogleAnalyticsDataSource.cs | 274 +++++++++++++++ .../Services/GoogleAnalyticsReportClient.cs | 59 ++++ .../IAnalyticsCompatibilityService.cs | 9 + .../Services/IAnalyticsDataSource.cs | 10 + .../Services/IAnalyticsSettingsResolver.cs | 9 + .../Services/IGoogleAnalyticsReportClient.cs | 11 + .../Services/SampleAnalyticsDataSource.cs | 172 +++++++++ ...GoogleEcommerceAnalyticsModule.Data.csproj | 3 +- .../Api/GoogleAnalyticsController.cs | 17 +- .../Module.cs | 17 +- .../module.manifest | 2 +- .../AnalyticsCompatibilityServiceTests.cs | 186 ++++++++++ .../AnalyticsServiceTests.cs | 330 ++++++++++++++++++ .../AnalyticsSettingsResolverTests.cs | 125 +++++++ .../GoogleAnalyticsDataSourceTests.cs | 329 +++++++++++++++++ .../GoogleAnalyticsReportClientTests.cs | 99 ++++++ .../HelloWorldTest.cs | 12 - .../SampleAnalyticsDataSourceTests.cs | 205 +++++++++++ ...oogleEcommerceAnalyticsModule.Tests.csproj | 5 +- 36 files changed, 2570 insertions(+), 19 deletions(-) create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEvent.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSummary.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSummaryCriteria.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsCompatibilityResult.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsReportCompatibility.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsCompatibilityService.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsCompatibilityService.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsSettingsResolver.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs create mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsCompatibilityServiceTests.cs create mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs create mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs create mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs create mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs delete mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/HelloWorldTest.cs create mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/SampleAnalyticsDataSourceTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index 461aee5..cbfa83b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ - 4.1003.0 + 4.1004.0 $(VersionSuffix)-$(BuildNumber) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs new file mode 100644 index 0000000..6cd0778 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +public class AnalyticsDimensionFilter +{ + public string DimensionName { get; set; } + + public IList Values { get; set; } = new List(); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEvent.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEvent.cs new file mode 100644 index 0000000..d6b581c --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEvent.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +public class AnalyticsEvent : ICloneable +{ + public string EventName { get; set; } + + public DateTime? OccurredAt { get; set; } + + public int Count { get; set; } + + public IDictionary Dimensions { get; set; } = new Dictionary(); + + public virtual object Clone() + { + var result = (AnalyticsEvent)MemberwiseClone(); + result.Dimensions = Dimensions != null ? new Dictionary(Dimensions) : null; + return result; + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs new file mode 100644 index 0000000..6539b82 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +public abstract class AnalyticsEventCriteriaBase +{ + public string StoreId { get; set; } + + public IList EventNames { get; set; } = new List(); + + public IList DimensionFilters { get; set; } = new List(); + + public DateTime? From { get; set; } + + public DateTime? To { get; set; } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs new file mode 100644 index 0000000..d1cabe1 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +public class AnalyticsEventSearchCriteria : AnalyticsEventCriteriaBase +{ + public IList DimensionNames { get; set; } = new List(); + + public string SortBy { get; set; } = ModuleConstants.SortBy.Date; + + public int Take { get; set; } = 20; + + public int Skip { get; set; } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs new file mode 100644 index 0000000..973a8e1 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +public class AnalyticsEventSearchResult : ICloneable +{ + public int TotalCount { get; set; } + + public IList Events { get; set; } = new List(); + + public virtual object Clone() + { + var result = (AnalyticsEventSearchResult)MemberwiseClone(); + result.Events = Events?.Select(x => x.CloneTyped()).ToList(); + return result; + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSummary.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSummary.cs new file mode 100644 index 0000000..4b9593d --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSummary.cs @@ -0,0 +1,17 @@ +using System; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +public class AnalyticsEventSummary : ICloneable +{ + public string EventName { get; set; } + + public int TotalCount { get; set; } + + public DateTime? LastOccurredAt { get; set; } + + public virtual object Clone() + { + return MemberwiseClone(); + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSummaryCriteria.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSummaryCriteria.cs new file mode 100644 index 0000000..82719a8 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSummaryCriteria.cs @@ -0,0 +1,5 @@ +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +public class AnalyticsEventSummaryCriteria : AnalyticsEventCriteriaBase +{ +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index 610021a..4cc8c3a 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using VirtoCommerce.Platform.Core.Settings; namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core @@ -15,6 +16,50 @@ public static class Permissions } } + public static class EventNames + { + public const string Search = "search"; + public const string ViewSearchResults = "view_search_results"; + public const string ViewItem = "view_item"; + public const string Login = "login"; + public const string SignUp = "sign_up"; + public const string AddToCart = "add_to_cart"; + public const string Purchase = "purchase"; + } + + public static class UserDimensions + { + public const string ContactId = "contact_id"; + public const string OrganizationId = "organization_id"; + public const string OrganizationName = "organization_name"; + public const string IsSalesRep = "is_sales_rep"; + public const string SessionKind = "session_kind"; + + public static string[] AllNames { get; } = { ContactId, OrganizationId, OrganizationName, IsSalesRep, SessionKind }; + } + + public static class SessionKinds + { + public const string Self = "self"; + public const string Impersonated = "impersonated"; + } + + public static class Dimensions + { + public const string SearchTerm = "searchTerm"; + public const string ItemId = "itemId"; + public const string ItemName = "itemName"; + public const string ItemListName = "itemListName"; + public const string EventName = "eventName"; + public const string DateHour = "dateHour"; + } + + public static class SortBy + { + public const string Date = "date"; + public const string Count = "count"; + } + public static class Settings { public const string DefaultGoogleAnalyticsUrl = "https://analytics.google.com/analytics/web/"; @@ -65,6 +110,55 @@ public static IEnumerable AllSettings } } + public static class DataApi + { + public static SettingDescriptor PropertyId { get; } = new SettingDescriptor + { + Name = "GoogleAnalytics4.DataApi.PropertyId", + GroupName = "Google Analytics 4", + ValueType = SettingValueType.ShortText + }; + + public static SettingDescriptor ServiceAccountJson { get; } = new SettingDescriptor + { + Name = "GoogleAnalytics4.DataApi.ServiceAccountJson", + GroupName = "Google Analytics 4", + ValueType = SettingValueType.SecureString + }; + + public static SettingDescriptor CacheTtlMinutes { get; } = new SettingDescriptor + { + Name = "GoogleAnalytics4.DataApi.CacheTtlMinutes", + GroupName = "Google Analytics 4", + ValueType = SettingValueType.PositiveInteger, + DefaultValue = 60 + }; + + public static SettingDescriptor SampleDataEnabled { get; } = new SettingDescriptor + { + Name = "GoogleAnalytics4.DataApi.SampleDataEnabled", + GroupName = "Google Analytics 4", + ValueType = SettingValueType.Boolean, + DefaultValue = false + }; + + public static IEnumerable AllSettings + { + get + { + yield return PropertyId; + yield return ServiceAccountJson; + yield return CacheTtlMinutes; + yield return SampleDataEnabled; + } + } + } + + public static IEnumerable AllSettings + { + get { return General.AllSettings.Concat(DataApi.AllSettings); } + } + public static IEnumerable StoreLevelSettings { get @@ -72,6 +166,10 @@ public static IEnumerable StoreLevelSettings yield return General.EnableTracking; yield return General.MeasurementId; yield return General.GtmContainerId; + yield return DataApi.PropertyId; + yield return DataApi.ServiceAccountJson; + yield return DataApi.CacheTtlMinutes; + yield return DataApi.SampleDataEnabled; } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs new file mode 100644 index 0000000..8465d85 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; + +public interface IAnalyticsService +{ + Task IsConfiguredAsync(string storeId); + + Task SearchEventsAsync(AnalyticsEventSearchCriteria criteria); + + Task> GetEventSummariesAsync(AnalyticsEventSummaryCriteria criteria); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsCompatibilityResult.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsCompatibilityResult.cs new file mode 100644 index 0000000..4b13eaa --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsCompatibilityResult.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; + +public class AnalyticsCompatibilityResult +{ + public bool Available { get; set; } + + public string ErrorMessage { get; set; } + + public IList Reports { get; set; } = new List(); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs new file mode 100644 index 0000000..ca8bf3d --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs @@ -0,0 +1,14 @@ +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; + +public class AnalyticsDataApiSettings +{ + public string PropertyId { get; set; } + + public string CredentialJson { get; set; } + + public int CacheTtlMinutes { get; set; } + + public bool SampleDataEnabled { get; set; } + + public bool IsGoogleConfigured => !string.IsNullOrEmpty(PropertyId); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs new file mode 100644 index 0000000..17f1e18 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; + +public class AnalyticsDataQuery +{ + public string PropertyId { get; set; } + + public string CredentialJson { get; set; } + + public IList EventNames { get; set; } = new List(); + + public IList DimensionFilters { get; set; } = new List(); + + public IList DimensionNames { get; set; } = new List(); + + public string SortBy { get; set; } = ModuleConstants.SortBy.Date; + + public DateTime? From { get; set; } + + public DateTime? To { get; set; } + + public int Take { get; set; } + + public int Skip { get; set; } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsReportCompatibility.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsReportCompatibility.cs new file mode 100644 index 0000000..3957f4d --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsReportCompatibility.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; + +public class AnalyticsReportCompatibility +{ + public string Report { get; set; } + + public bool Compatible { get; set; } + + public IList IncompatibleDimensions { get; set; } = new List(); + + public IList IncompatibleMetrics { get; set; } = new List(); + + public string ErrorMessage { get; set; } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsCompatibilityService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsCompatibilityService.cs new file mode 100644 index 0000000..7aeea3d --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsCompatibilityService.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Google.Analytics.Data.V1Beta; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +public class AnalyticsCompatibilityService : IAnalyticsCompatibilityService +{ + private const string SearchTermsReport = "searchTerms"; + private const string BrowsedProductsReport = "browsedProducts"; + private const string ProbeFilterValue = "compatibility-probe"; + + private readonly IAnalyticsSettingsResolver _settingsResolver; + private readonly GoogleAnalyticsDataSource _googleAnalyticsDataSource; + private readonly IGoogleAnalyticsReportClient _reportClient; + + public AnalyticsCompatibilityService( + IAnalyticsSettingsResolver settingsResolver, + GoogleAnalyticsDataSource googleAnalyticsDataSource, + IGoogleAnalyticsReportClient reportClient) + { + _settingsResolver = settingsResolver; + _googleAnalyticsDataSource = googleAnalyticsDataSource; + _reportClient = reportClient; + } + + public virtual async Task CheckCompatibilityAsync(string storeId) + { + var result = new AnalyticsCompatibilityResult(); + + try + { + var settings = await _settingsResolver.ResolveAsync(storeId); + + if (settings.SampleDataEnabled) + { + result.ErrorMessage = "Sample data mode is enabled; the Google Analytics compatibility check does not apply to the sample data source."; + return result; + } + + if (!settings.IsGoogleConfigured) + { + result.ErrorMessage = "Google Analytics 4 Data API property is not configured for the store."; + return result; + } + + result.Available = true; + result.Reports.Add(await CheckReportAsync(SearchTermsReport, settings, + _googleAnalyticsDataSource.BuildEventReportRequest(CreateSearchTermsQuery(settings)))); + result.Reports.Add(await CheckReportAsync(BrowsedProductsReport, settings, + _googleAnalyticsDataSource.BuildItemReportRequest(CreateBrowsedProductsQuery(settings)))); + } + catch (Exception ex) + { + result.Available = false; + result.ErrorMessage = ex.Message; + } + + return result; + } + + protected virtual async Task CheckReportAsync(string reportName, AnalyticsDataApiSettings settings, RunReportRequest reportRequest) + { + var result = new AnalyticsReportCompatibility { Report = reportName }; + + try + { + var request = new CheckCompatibilityRequest + { + Property = reportRequest.Property, + CompatibilityFilter = Compatibility.Incompatible, + }; + request.Dimensions.AddRange(reportRequest.Dimensions); + request.Metrics.AddRange(reportRequest.Metrics); + if (reportRequest.DimensionFilter != null) + { + request.DimensionFilter = reportRequest.DimensionFilter; + } + + var response = await _reportClient.CheckCompatibilityAsync(settings.CredentialJson, request); + var requestedFields = GetRequestedFieldNames(reportRequest); + + result.IncompatibleDimensions = response.DimensionCompatibilities + .Where(x => x.Compatibility == Compatibility.Incompatible) + .Select(x => x.DimensionMetadata?.ApiName) + .Where(x => !string.IsNullOrEmpty(x) && requestedFields.Contains(x)) + .ToList(); + result.IncompatibleMetrics = response.MetricCompatibilities + .Where(x => x.Compatibility == Compatibility.Incompatible) + .Select(x => x.MetricMetadata?.ApiName) + .Where(x => !string.IsNullOrEmpty(x) && requestedFields.Contains(x)) + .ToList(); + result.Compatible = result.IncompatibleDimensions.Count == 0 && result.IncompatibleMetrics.Count == 0; + } + catch (Exception ex) + { + result.ErrorMessage = ex.Message; + } + + return result; + } + + protected virtual AnalyticsDataQuery CreateSearchTermsQuery(AnalyticsDataApiSettings settings) + { + return new AnalyticsDataQuery + { + PropertyId = settings.PropertyId, + CredentialJson = settings.CredentialJson, + EventNames = new List { ModuleConstants.EventNames.Search, ModuleConstants.EventNames.ViewSearchResults }, + DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm }, + DimensionFilters = CreateUserDimensionFilters(), + Take = 1, + }; + } + + protected virtual AnalyticsDataQuery CreateBrowsedProductsQuery(AnalyticsDataApiSettings settings) + { + return new AnalyticsDataQuery + { + PropertyId = settings.PropertyId, + CredentialJson = settings.CredentialJson, + EventNames = new List { ModuleConstants.EventNames.ViewItem }, + DimensionNames = new List { ModuleConstants.Dimensions.ItemId, ModuleConstants.Dimensions.ItemName }, + DimensionFilters = CreateUserDimensionFilters(), + Take = 1, + }; + } + + protected virtual IList CreateUserDimensionFilters() + { + var sessionKindFilter = AbstractTypeFactory.TryCreateInstance(); + sessionKindFilter.DimensionName = ModuleConstants.UserDimensions.SessionKind; + sessionKindFilter.Values = new List { ModuleConstants.SessionKinds.Self }; + + var organizationFilter = AbstractTypeFactory.TryCreateInstance(); + organizationFilter.DimensionName = ModuleConstants.UserDimensions.OrganizationId; + organizationFilter.Values = new List { ProbeFilterValue }; + + return new List { sessionKindFilter, organizationFilter }; + } + + protected virtual ISet GetRequestedFieldNames(RunReportRequest request) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + result.UnionWith(request.Dimensions.Select(x => x.Name)); + result.UnionWith(request.Metrics.Select(x => x.Name)); + CollectFilterFieldNames(request.DimensionFilter, result); + return result; + } + + protected virtual void CollectFilterFieldNames(FilterExpression expression, ISet fieldNames) + { + if (expression == null) + { + return; + } + + if (!string.IsNullOrEmpty(expression.Filter?.FieldName)) + { + fieldNames.Add(expression.Filter.FieldName); + } + + if (expression.AndGroup != null) + { + foreach (var child in expression.AndGroup.Expressions) + { + CollectFilterFieldNames(child, fieldNames); + } + } + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs new file mode 100644 index 0000000..97003aa --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.Platform.Core.Caching; +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +public class AnalyticsService : IAnalyticsService +{ + private const int SummaryRowsLimit = 100_000; + + private readonly IAnalyticsSettingsResolver _settingsResolver; + private readonly IPlatformMemoryCache _platformMemoryCache; + private readonly GoogleAnalyticsDataSource _googleAnalyticsDataSource; + private readonly SampleAnalyticsDataSource _sampleAnalyticsDataSource; + private readonly ILogger _logger; + + public AnalyticsService( + IAnalyticsSettingsResolver settingsResolver, + IPlatformMemoryCache platformMemoryCache, + GoogleAnalyticsDataSource googleAnalyticsDataSource, + SampleAnalyticsDataSource sampleAnalyticsDataSource, + ILogger logger) + { + _settingsResolver = settingsResolver; + _platformMemoryCache = platformMemoryCache; + _googleAnalyticsDataSource = googleAnalyticsDataSource; + _sampleAnalyticsDataSource = sampleAnalyticsDataSource; + _logger = logger; + } + + protected virtual TimeSpan FailureCacheTtl => TimeSpan.FromSeconds(60); + + public virtual async Task IsConfiguredAsync(string storeId) + { + try + { + var settings = await _settingsResolver.ResolveAsync(storeId); + return settings.SampleDataEnabled || settings.IsGoogleConfigured; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to resolve Google Analytics Data API settings for store {StoreId}", storeId); + return false; + } + } + + public virtual async Task SearchEventsAsync(AnalyticsEventSearchCriteria criteria) + { + ArgumentNullException.ThrowIfNull(criteria); + + try + { + var settings = await _settingsResolver.ResolveAsync(criteria.StoreId); + var dataSource = ResolveDataSource(settings); + if (dataSource == null) + { + return AbstractTypeFactory.TryCreateInstance(); + } + + var cacheKey = CacheKey.With(GetType(), nameof(SearchEventsAsync), GetCriteriaCacheKey(criteria)); + var result = await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, async cacheOptions => + { + cacheOptions.AbsoluteExpirationRelativeToNow = GetCacheTtl(settings); + + try + { + var query = CreateQuery(settings, criteria); + query.DimensionNames = criteria.DimensionNames; + query.SortBy = criteria.SortBy; + query.Take = criteria.Take; + query.Skip = criteria.Skip; + + return await dataSource.GetRowsAsync(query); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Google Analytics events search failed for store {StoreId}", criteria.StoreId); + cacheOptions.AbsoluteExpirationRelativeToNow = FailureCacheTtl; + return AbstractTypeFactory.TryCreateInstance(); + } + }); + + return result?.CloneTyped() ?? AbstractTypeFactory.TryCreateInstance(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Google Analytics events search failed for store {StoreId}", criteria.StoreId); + return AbstractTypeFactory.TryCreateInstance(); + } + } + + public virtual async Task> GetEventSummariesAsync(AnalyticsEventSummaryCriteria criteria) + { + ArgumentNullException.ThrowIfNull(criteria); + + try + { + var settings = await _settingsResolver.ResolveAsync(criteria.StoreId); + var dataSource = ResolveDataSource(settings); + if (dataSource == null) + { + return CreateSummaries(criteria, new List()); + } + + var cacheKey = CacheKey.With(GetType(), nameof(GetEventSummariesAsync), GetCriteriaCacheKey(criteria)); + var result = await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, async cacheOptions => + { + cacheOptions.AbsoluteExpirationRelativeToNow = GetCacheTtl(settings); + + try + { + var query = CreateQuery(settings, criteria); + query.Take = SummaryRowsLimit; + + var rows = await dataSource.GetRowsAsync(query); + return CreateSummaries(criteria, rows.Events); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Google Analytics event summaries failed for store {StoreId}", criteria.StoreId); + cacheOptions.AbsoluteExpirationRelativeToNow = FailureCacheTtl; + return CreateSummaries(criteria, new List()); + } + }); + + return result?.Select(x => x.CloneTyped()).ToList() ?? CreateSummaries(criteria, new List()); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Google Analytics event summaries failed for store {StoreId}", criteria.StoreId); + return CreateSummaries(criteria, new List()); + } + } + + protected virtual IAnalyticsDataSource ResolveDataSource(AnalyticsDataApiSettings settings) + { + if (settings.SampleDataEnabled) + { + return _sampleAnalyticsDataSource; + } + + return settings.IsGoogleConfigured ? _googleAnalyticsDataSource : null; + } + + protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settings, AnalyticsEventCriteriaBase criteria) + { + return new AnalyticsDataQuery + { + PropertyId = settings.PropertyId, + CredentialJson = settings.CredentialJson, + EventNames = criteria.EventNames, + DimensionFilters = criteria.DimensionFilters, + From = criteria.From, + To = criteria.To, + }; + } + + protected virtual IList CreateSummaries(AnalyticsEventSummaryCriteria criteria, IList events) + { + var groups = events + .Where(x => !string.IsNullOrEmpty(x.EventName)) + .GroupBy(x => x.EventName) + .ToDictionary(x => x.Key, x => x.ToList()); + + var eventNames = criteria.EventNames?.Count > 0 ? criteria.EventNames : groups.Keys.ToList(); + + return eventNames + .Select(eventName => + { + var summary = AbstractTypeFactory.TryCreateInstance(); + summary.EventName = eventName; + + if (groups.TryGetValue(eventName, out var group)) + { + summary.TotalCount = group.Sum(x => x.Count); + summary.LastOccurredAt = group.Max(x => x.OccurredAt); + } + + return summary; + }) + .ToList(); + } + + protected virtual string GetCriteriaCacheKey(AnalyticsEventCriteriaBase criteria) + { + return JsonConvert.SerializeObject(criteria); + } + + private static TimeSpan GetCacheTtl(AnalyticsDataApiSettings settings) + { + return TimeSpan.FromMinutes(Math.Max(1, settings.CacheTtlMinutes)); + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs new file mode 100644 index 0000000..e9685f2 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.Platform.Core.Common; +using VirtoCommerce.Platform.Core.Settings; +using VirtoCommerce.StoreModule.Core.Services; +using DataApiSettings = VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.ModuleConstants.Settings.DataApi; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +public class AnalyticsSettingsResolver : IAnalyticsSettingsResolver +{ + private readonly IStoreService _storeService; + private readonly ISettingsManager _settingsManager; + + public AnalyticsSettingsResolver(IStoreService storeService, ISettingsManager settingsManager) + { + _storeService = storeService; + _settingsManager = settingsManager; + } + + public virtual async Task ResolveAsync(string storeId) + { + var store = string.IsNullOrEmpty(storeId) ? null : await _storeService.GetNoCloneAsync(storeId); + var storeSettings = store?.Settings; + + return new AnalyticsDataApiSettings + { + PropertyId = await GetSettingAsync(storeSettings, DataApiSettings.PropertyId), + CredentialJson = await GetSettingAsync(storeSettings, DataApiSettings.ServiceAccountJson), + CacheTtlMinutes = await GetSettingAsync(storeSettings, DataApiSettings.CacheTtlMinutes), + SampleDataEnabled = await GetSettingAsync(storeSettings, DataApiSettings.SampleDataEnabled), + }; + } + + protected virtual async Task GetSettingAsync(ICollection storeSettings, SettingDescriptor descriptor) + { + var storeEntry = storeSettings?.FirstOrDefault(x => x.Name.EqualsIgnoreCase(descriptor.Name)); + if (storeEntry?.Value != null) + { + return storeSettings.GetValue(descriptor); + } + + return await _settingsManager.GetValueAsync(descriptor); + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs new file mode 100644 index 0000000..5330bef --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Google.Analytics.Data.V1Beta; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +public class GoogleAnalyticsDataSource : IAnalyticsDataSource +{ + private const string NotSetValue = "(not set)"; + private const string UserDimensionPrefix = "customUser:"; + private const string EventCountMetric = "eventCount"; + private const string ItemsViewedMetric = "itemsViewed"; + private const string DateHourFormat = "yyyyMMddHH"; + private const string DateFormat = "yyyy-MM-dd"; + private const string DefaultStartDate = "2015-08-14"; + private const string DefaultEndDate = "today"; + + private static readonly string[] ItemDimensionNames = + { + ModuleConstants.Dimensions.ItemId, + ModuleConstants.Dimensions.ItemName, + ModuleConstants.Dimensions.ItemListName, + }; + + private readonly IGoogleAnalyticsReportClient _reportClient; + + public GoogleAnalyticsDataSource(IGoogleAnalyticsReportClient reportClient) + { + _reportClient = reportClient; + } + + public virtual Task GetRowsAsync(AnalyticsDataQuery query) + { + return HasItemDimensions(query) ? GetItemScopedRowsAsync(query) : GetEventScopedRowsAsync(query); + } + + protected virtual async Task GetEventScopedRowsAsync(AnalyticsDataQuery query) + { + var response = await _reportClient.RunReportAsync(query.CredentialJson, BuildEventReportRequest(query)); + return MapResponse(response, query); + } + + // GA4 compatibility of item-scoped dimensions combined with user-scoped custom dimensions is unverified; + // kept as a separate path so it can be reworked without touching the main query path. + protected virtual async Task GetItemScopedRowsAsync(AnalyticsDataQuery query) + { + var response = await _reportClient.RunReportAsync(query.CredentialJson, BuildItemReportRequest(query)); + var result = MapResponse(response, query); + + var eventName = query.EventNames?.Count == 1 ? query.EventNames[0] : null; + if (!string.IsNullOrEmpty(eventName)) + { + foreach (var analyticsEvent in result.Events) + { + analyticsEvent.EventName = eventName; + } + } + + return result; + } + + public virtual RunReportRequest BuildEventReportRequest(AnalyticsDataQuery query) + { + var request = CreateRequest(query); + + request.Dimensions.Add(new Dimension { Name = ModuleConstants.Dimensions.EventName }); + AddDateHourAndExtraDimensions(request, query); + + request.Metrics.Add(new Metric { Name = EventCountMetric }); + AddOrderBy(request, query, EventCountMetric); + + return request; + } + + // Item-scoped report shape per the GA4 schema: metric itemsViewed, eventName used only as a dimension filter. + public virtual RunReportRequest BuildItemReportRequest(AnalyticsDataQuery query) + { + var request = CreateRequest(query); + + AddDateHourAndExtraDimensions(request, query); + + request.Metrics.Add(new Metric { Name = ItemsViewedMetric }); + AddOrderBy(request, query, ItemsViewedMetric); + + return request; + } + + protected virtual RunReportRequest CreateRequest(AnalyticsDataQuery query) + { + var request = new RunReportRequest + { + Property = $"properties/{query.PropertyId}", + Limit = query.Take > 0 ? query.Take : 1, + Offset = query.Skip, + }; + + request.DateRanges.Add(new DateRange + { + StartDate = query.From?.ToString(DateFormat, CultureInfo.InvariantCulture) ?? DefaultStartDate, + EndDate = query.To?.ToString(DateFormat, CultureInfo.InvariantCulture) ?? DefaultEndDate, + }); + + var dimensionFilter = BuildDimensionFilter(query); + if (dimensionFilter != null) + { + request.DimensionFilter = dimensionFilter; + } + + return request; + } + + protected virtual void AddDateHourAndExtraDimensions(RunReportRequest request, AnalyticsDataQuery query) + { + if (!IsCountSort(query)) + { + request.Dimensions.Add(new Dimension { Name = ModuleConstants.Dimensions.DateHour }); + } + + foreach (var dimensionName in (query.DimensionNames ?? Array.Empty()) + .Where(x => x != ModuleConstants.Dimensions.EventName && x != ModuleConstants.Dimensions.DateHour)) + { + request.Dimensions.Add(new Dimension { Name = MapDimensionName(dimensionName) }); + } + } + + protected virtual void AddOrderBy(RunReportRequest request, AnalyticsDataQuery query, string metricName) + { + request.OrderBys.Add(IsCountSort(query) + ? new OrderBy + { + Desc = true, + Metric = new OrderBy.Types.MetricOrderBy { MetricName = metricName }, + } + : new OrderBy + { + Desc = true, + Dimension = new OrderBy.Types.DimensionOrderBy { DimensionName = ModuleConstants.Dimensions.DateHour }, + }); + } + + protected virtual FilterExpression BuildDimensionFilter(AnalyticsDataQuery query) + { + var expressions = new List(); + + if (query.EventNames?.Count > 0) + { + expressions.Add(CreateInListExpression(ModuleConstants.Dimensions.EventName, query.EventNames)); + } + + foreach (var filter in (query.DimensionFilters ?? Array.Empty()) + .Where(x => !string.IsNullOrEmpty(x.DimensionName) && x.Values?.Count > 0)) + { + expressions.Add(CreateInListExpression(MapDimensionName(filter.DimensionName), filter.Values)); + } + + if (expressions.Count == 0) + { + return null; + } + + if (expressions.Count == 1) + { + return expressions[0]; + } + + var result = new FilterExpression { AndGroup = new FilterExpressionList() }; + result.AndGroup.Expressions.AddRange(expressions); + return result; + } + + protected virtual AnalyticsEventSearchResult MapResponse(RunReportResponse response, AnalyticsDataQuery query) + { + var result = AbstractTypeFactory.TryCreateInstance(); + result.TotalCount = response.RowCount; + + if (query.Take <= 0) + { + return result; + } + + var headers = response.DimensionHeaders.Select(x => x.Name).ToList(); + + foreach (var row in response.Rows) + { + var analyticsEvent = AbstractTypeFactory.TryCreateInstance(); + + for (var i = 0; i < headers.Count && i < row.DimensionValues.Count; i++) + { + MapDimensionValue(analyticsEvent, headers[i], row.DimensionValues[i].Value); + } + + analyticsEvent.Count = row.MetricValues.Count > 0 + && int.TryParse(row.MetricValues[0].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count) + ? count + : 0; + + result.Events.Add(analyticsEvent); + } + + return result; + } + + protected virtual void MapDimensionValue(AnalyticsEvent analyticsEvent, string dimensionName, string value) + { + if (string.IsNullOrEmpty(value) || value == NotSetValue) + { + return; + } + + if (dimensionName == ModuleConstants.Dimensions.EventName) + { + analyticsEvent.EventName = value; + } + else if (dimensionName == ModuleConstants.Dimensions.DateHour) + { + analyticsEvent.OccurredAt = ParseDateHour(value); + } + else + { + analyticsEvent.Dimensions[UnmapDimensionName(dimensionName)] = value; + } + } + + protected virtual string MapDimensionName(string dimensionName) + { + return ModuleConstants.UserDimensions.AllNames.Contains(dimensionName) + ? UserDimensionPrefix + dimensionName + : dimensionName; + } + + protected virtual string UnmapDimensionName(string dimensionName) + { + return dimensionName.StartsWith(UserDimensionPrefix, StringComparison.Ordinal) + ? dimensionName[UserDimensionPrefix.Length..] + : dimensionName; + } + + protected virtual bool HasItemDimensions(AnalyticsDataQuery query) + { + return query.DimensionNames?.Any(x => ItemDimensionNames.Contains(x)) == true + || query.DimensionFilters?.Any(x => ItemDimensionNames.Contains(x.DimensionName)) == true; + } + + protected static bool IsCountSort(AnalyticsDataQuery query) + { + return ModuleConstants.SortBy.Count.EqualsIgnoreCase(query.SortBy); + } + + private static FilterExpression CreateInListExpression(string fieldName, IEnumerable values) + { + var inListFilter = new Filter.Types.InListFilter(); + inListFilter.Values.AddRange(values); + + return new FilterExpression + { + Filter = new Filter { FieldName = fieldName, InListFilter = inListFilter }, + }; + } + + private static DateTime? ParseDateHour(string value) + { + return DateTime.TryParseExact(value, DateHourFormat, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var result) + ? result + : null; + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs new file mode 100644 index 0000000..4af89ba --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs @@ -0,0 +1,59 @@ +using System.Threading.Tasks; +using Google.Analytics.Data.V1Beta; +using Google.Apis.Auth.OAuth2; +using Newtonsoft.Json.Linq; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +public class GoogleAnalyticsReportClient : IGoogleAnalyticsReportClient +{ + private const string CredentialTypeProperty = "type"; + + private static readonly string[] AnalyticsReadOnlyScopes = { "https://www.googleapis.com/auth/analytics.readonly" }; + + public virtual async Task RunReportAsync(string credentialJson, RunReportRequest request) + { + var client = await CreateClientAsync(credentialJson); + return await client.RunReportAsync(request); + } + + public virtual async Task CheckCompatibilityAsync(string credentialJson, CheckCompatibilityRequest request) + { + var client = await CreateClientAsync(credentialJson); + return await client.CheckCompatibilityAsync(request); + } + + protected virtual async Task CreateClientAsync(string credentialJson) + { + var builder = new BetaAnalyticsDataClientBuilder + { + GoogleCredential = await ResolveCredentialAsync(credentialJson), + }; + + return await builder.BuildAsync(); + } + + protected virtual async Task ResolveCredentialAsync(string credentialJson) + { + var credential = string.IsNullOrWhiteSpace(credentialJson) + ? await GetApplicationDefaultCredentialAsync() + : CreateCredentialFromJson(credentialJson); + + return credential.IsCreateScopedRequired + ? credential.CreateScoped(AnalyticsReadOnlyScopes) + : credential; + } + + // Supports both service_account keys and Workload Identity Federation external_account configurations; + // the declared credential type is passed through because CredentialFactory has no multi-type overload. + protected virtual GoogleCredential CreateCredentialFromJson(string credentialJson) + { + var credentialType = JObject.Parse(credentialJson)[CredentialTypeProperty]?.Value(); + return CredentialFactory.FromJson(credentialJson, credentialType); + } + + protected virtual Task GetApplicationDefaultCredentialAsync() + { + return GoogleCredential.GetApplicationDefaultAsync(); + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsCompatibilityService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsCompatibilityService.cs new file mode 100644 index 0000000..b3474db --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsCompatibilityService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +public interface IAnalyticsCompatibilityService +{ + Task CheckCompatibilityAsync(string storeId); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs new file mode 100644 index 0000000..476389a --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +public interface IAnalyticsDataSource +{ + Task GetRowsAsync(AnalyticsDataQuery query); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsSettingsResolver.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsSettingsResolver.cs new file mode 100644 index 0000000..2d10e08 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsSettingsResolver.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +public interface IAnalyticsSettingsResolver +{ + Task ResolveAsync(string storeId); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs new file mode 100644 index 0000000..d95cebf --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using Google.Analytics.Data.V1Beta; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +public interface IGoogleAnalyticsReportClient +{ + Task RunReportAsync(string credentialJson, RunReportRequest request); + + Task CheckCompatibilityAsync(string credentialJson, CheckCompatibilityRequest request); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs new file mode 100644 index 0000000..6f2091f --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +public class SampleAnalyticsDataSource : IAnalyticsDataSource +{ + private const int MaxHourBuckets = 24 * 14; + private const int BucketDensityPercent = 30; + + private static readonly string[] DefaultEventNames = + { + ModuleConstants.EventNames.Search, + ModuleConstants.EventNames.ViewSearchResults, + ModuleConstants.EventNames.ViewItem, + ModuleConstants.EventNames.Login, + ModuleConstants.EventNames.SignUp, + ModuleConstants.EventNames.AddToCart, + ModuleConstants.EventNames.Purchase, + }; + + private static readonly string[] SampleSearchTerms = { "drill", "hammer", "socket wrench", "paint", "ladder", "hex bolt" }; + private static readonly string[] SampleItemCodes = { "SAMPLE-0001", "SAMPLE-0002", "SAMPLE-0003", "SAMPLE-0004", "SAMPLE-0005", "SAMPLE-0006" }; + private static readonly string[] SampleItemNames = { "Cordless Drill 18V", "Claw Hammer", "Socket Wrench Set", "Acrylic Paint 5L", "Aluminum Ladder", "Hex Bolt M8" }; + private static readonly string[] SampleItemListNames = { "Search Results", "Related Products", "Category" }; + + public virtual Task GetRowsAsync(AnalyticsDataQuery query) + { + var result = AbstractTypeFactory.TryCreateInstance(); + + var to = TruncateToHour(query.To ?? DateTime.UtcNow); + var from = query.From ?? to.AddHours(-(MaxHourBuckets - 1)); + var eventNames = query.EventNames?.Count > 0 ? query.EventNames : DefaultEventNames; + var filterSignature = GetFilterSignature(query); + + var events = new List(); + + for (var bucketIndex = 0; bucketIndex < MaxHourBuckets; bucketIndex++) + { + var bucket = to.AddHours(-bucketIndex); + if (bucket < from) + { + break; + } + + foreach (var eventName in eventNames) + { + var seed = GetStableHash(eventName, bucketIndex.ToString(CultureInfo.InvariantCulture), filterSignature); + if (seed % 100 >= BucketDensityPercent) + { + continue; + } + + var analyticsEvent = AbstractTypeFactory.TryCreateInstance(); + analyticsEvent.EventName = eventName; + analyticsEvent.OccurredAt = bucket; + analyticsEvent.Count = 1 + seed / 100 % 5; + + foreach (var dimensionName in query.DimensionNames ?? Array.Empty()) + { + var value = GetDimensionValue(dimensionName, query.DimensionFilters, seed); + if (value != null) + { + analyticsEvent.Dimensions[dimensionName] = value; + } + } + + events.Add(analyticsEvent); + } + } + + if (ModuleConstants.SortBy.Count.EqualsIgnoreCase(query.SortBy)) + { + events = AggregateByDimensionTuple(events); + } + + result.TotalCount = events.Count; + result.Events = query.Take > 0 ? events.Skip(query.Skip).Take(query.Take).ToList() : new List(); + + return Task.FromResult(result); + } + + protected virtual List AggregateByDimensionTuple(List events) + { + return events + .GroupBy(GetDimensionTupleKey) + .Select(group => + { + var first = group.First(); + + var analyticsEvent = AbstractTypeFactory.TryCreateInstance(); + analyticsEvent.EventName = first.EventName; + analyticsEvent.Count = group.Sum(x => x.Count); + + foreach (var dimension in first.Dimensions) + { + analyticsEvent.Dimensions[dimension.Key] = dimension.Value; + } + + return analyticsEvent; + }) + .OrderByDescending(x => x.Count) + .ThenBy(GetDimensionTupleKey, StringComparer.Ordinal) + .ToList(); + } + + protected virtual string GetDimensionTupleKey(AnalyticsEvent analyticsEvent) + { + var dimensions = string.Join(",", analyticsEvent.Dimensions + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => $"{x.Key}={x.Value}")); + + return $"{analyticsEvent.EventName}|{dimensions}"; + } + + protected virtual string GetDimensionValue(string dimensionName, IList filters, int seed) + { + var filter = filters?.FirstOrDefault(x => x.DimensionName == dimensionName && x.Values?.Count > 0); + if (filter != null) + { + return filter.Values[seed % filter.Values.Count]; + } + + return dimensionName switch + { + ModuleConstants.Dimensions.SearchTerm => SampleSearchTerms[seed % SampleSearchTerms.Length], + ModuleConstants.Dimensions.ItemId => SampleItemCodes[seed % SampleItemCodes.Length], + ModuleConstants.Dimensions.ItemName => SampleItemNames[seed % SampleItemNames.Length], + ModuleConstants.Dimensions.ItemListName => SampleItemListNames[seed % SampleItemListNames.Length], + ModuleConstants.UserDimensions.SessionKind => ModuleConstants.SessionKinds.Self, + _ => $"{dimensionName}-{seed % 5}", + }; + } + + private static string GetFilterSignature(AnalyticsDataQuery query) + { + return string.Join(";", (query.DimensionFilters ?? Array.Empty()) + .Where(x => !string.IsNullOrEmpty(x.DimensionName)) + .OrderBy(x => x.DimensionName, StringComparer.Ordinal) + .Select(x => $"{x.DimensionName}={string.Join(",", x.Values ?? Array.Empty())}")); + } + + // FNV-1a: string.GetHashCode is randomized per process, so it cannot seed deterministic sample data. + private static int GetStableHash(params string[] parts) + { + var hash = 2166136261u; + + foreach (var character in string.Join("|", parts)) + { + unchecked + { + hash ^= character; + hash *= 16777619u; + } + } + + return (int)(hash % int.MaxValue); + } + + private static DateTime TruncateToHour(DateTime value) + { + var utcValue = value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : DateTime.SpecifyKind(value, DateTimeKind.Utc); + return new DateTime(utcValue.Year, utcValue.Month, utcValue.Day, utcValue.Hour, 0, 0, DateTimeKind.Utc); + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj index 2b35435..ff64d6e 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj @@ -12,7 +12,8 @@ false - + + diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Controllers/Api/GoogleAnalyticsController.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Controllers/Api/GoogleAnalyticsController.cs index 96ad76f..3bdd359 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Controllers/Api/GoogleAnalyticsController.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Controllers/Api/GoogleAnalyticsController.cs @@ -4,6 +4,8 @@ using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; using VirtoCommerce.Platform.Core.Settings; using GoogleSettings = VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.ModuleConstants.Settings.General; @@ -14,11 +16,16 @@ public class GoogleAnalyticsController : Controller { private readonly IGoogleAnalyticsSettingsManager _settings; private readonly ISettingsManager _settingsManager; + private readonly IAnalyticsCompatibilityService _compatibilityService; - public GoogleAnalyticsController(IGoogleAnalyticsSettingsManager settings, ISettingsManager settingsManager) + public GoogleAnalyticsController( + IGoogleAnalyticsSettingsManager settings, + ISettingsManager settingsManager, + IAnalyticsCompatibilityService compatibilityService) { _settings = settings; _settingsManager = settingsManager; + _compatibilityService = compatibilityService; } [HttpGet] @@ -28,6 +35,14 @@ public Task GetStoreSettings(string storeId) return _settings.GetAsync(storeId); } + [HttpGet] + [Route("{storeId}/compatibility")] + [Authorize(ModuleConstants.Security.Permissions.Access)] + public async Task> CheckCompatibility(string storeId) + { + return Ok(await _compatibilityService.CheckCompatibilityAsync(storeId)); + } + [HttpGet] [Route("redirect")] [Authorize(ModuleConstants.Security.Permissions.Access)] diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs index 3fa585f..b1f4476 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs @@ -2,8 +2,10 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; +using VirtoCommerce.Platform.Core.Common; using VirtoCommerce.Platform.Core.Modularity; using VirtoCommerce.Platform.Core.Security; using VirtoCommerce.Platform.Core.Settings; @@ -18,6 +20,19 @@ public class Module : IModule public void Initialize(IServiceCollection serviceCollection) { serviceCollection.AddTransient(); + serviceCollection.AddSingleton(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + + AbstractTypeFactory.RegisterType(); + AbstractTypeFactory.RegisterType(); + AbstractTypeFactory.RegisterType(); + AbstractTypeFactory.RegisterType(); + AbstractTypeFactory.RegisterType(); + AbstractTypeFactory.RegisterType(); } public void PostInitialize(IApplicationBuilder appBuilder) @@ -32,7 +47,7 @@ public void PostInitialize(IApplicationBuilder appBuilder) // register settings var settingsRegistrar = serviceProvider.GetRequiredService(); - settingsRegistrar.RegisterSettings(ModuleConstants.Settings.General.AllSettings, ModuleInfo.Id); + settingsRegistrar.RegisterSettings(ModuleConstants.Settings.AllSettings, ModuleInfo.Id); //Register store level settings settingsRegistrar.RegisterSettingsForType(ModuleConstants.Settings.StoreLevelSettings, nameof(Store)); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/module.manifest b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/module.manifest index e44f3f0..e246727 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/module.manifest +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/module.manifest @@ -1,7 +1,7 @@ VirtoCommerce.GoogleEcommerceAnalytics - 4.1003.0 + 4.1004.0 3.1039.0 diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsCompatibilityServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsCompatibilityServiceTests.cs new file mode 100644 index 0000000..516d702 --- /dev/null +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsCompatibilityServiceTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Google.Analytics.Data.V1Beta; +using Moq; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; +using Xunit; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; + +public class AnalyticsCompatibilityServiceTests +{ + private const string StoreId = "test-store"; + + private readonly Mock _settingsResolverMock = new(); + private readonly Mock _reportClientMock = new(); + private readonly List _capturedRequests = new(); + + [Fact] + public async Task CheckCompatibilityAsync_SampleDataEnabled_ReportsUnavailableWithoutCallingGoogle() + { + SetupSettings(new AnalyticsDataApiSettings { PropertyId = "123456", SampleDataEnabled = true }); + var service = CreateService(); + + var result = await service.CheckCompatibilityAsync(StoreId); + + Assert.False(result.Available); + Assert.Contains("Sample data", result.ErrorMessage); + Assert.Empty(result.Reports); + _reportClientMock.Verify(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task CheckCompatibilityAsync_NoPropertyId_ReportsErrorWithoutCallingGoogle() + { + SetupSettings(new AnalyticsDataApiSettings()); + var service = CreateService(); + + var result = await service.CheckCompatibilityAsync(StoreId); + + Assert.False(result.Available); + Assert.Contains("not configured", result.ErrorMessage); + Assert.Empty(result.Reports); + _reportClientMock.Verify(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task CheckCompatibilityAsync_ResolverThrows_ReturnsErrorPayload() + { + _settingsResolverMock + .Setup(x => x.ResolveAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + var service = CreateService(); + + var result = await service.CheckCompatibilityAsync(StoreId); + + Assert.False(result.Available); + Assert.Equal("boom", result.ErrorMessage); + } + + [Fact] + public async Task CheckCompatibilityAsync_BuildsCanonicalReportRequests() + { + SetupSettings(CreateGoogleSettings()); + SetupClient(new CheckCompatibilityResponse()); + var service = CreateService(); + + var result = await service.CheckCompatibilityAsync(StoreId); + + Assert.True(result.Available); + Assert.Null(result.ErrorMessage); + Assert.Equal(new[] { "searchTerms", "browsedProducts" }, result.Reports.Select(x => x.Report)); + Assert.All(result.Reports, x => Assert.True(x.Compatible)); + Assert.All(result.Reports, x => Assert.Empty(x.IncompatibleDimensions)); + Assert.All(result.Reports, x => Assert.Empty(x.IncompatibleMetrics)); + + Assert.Equal(2, _capturedRequests.Count); + + var searchTermsRequest = _capturedRequests[0]; + Assert.Equal("properties/123456", searchTermsRequest.Property); + Assert.Equal(Compatibility.Incompatible, searchTermsRequest.CompatibilityFilter); + Assert.Equal(new[] { "eventName", "dateHour", "searchTerm" }, searchTermsRequest.Dimensions.Select(x => x.Name)); + Assert.Equal("eventCount", Assert.Single(searchTermsRequest.Metrics).Name); + Assert.Equal( + new[] { "eventName", "customUser:session_kind", "customUser:organization_id" }, + searchTermsRequest.DimensionFilter.AndGroup.Expressions.Select(x => x.Filter.FieldName)); + Assert.Equal( + new[] { "search", "view_search_results" }, + searchTermsRequest.DimensionFilter.AndGroup.Expressions[0].Filter.InListFilter.Values); + + var browsedProductsRequest = _capturedRequests[1]; + Assert.Equal("properties/123456", browsedProductsRequest.Property); + Assert.Equal(new[] { "dateHour", "itemId", "itemName" }, browsedProductsRequest.Dimensions.Select(x => x.Name)); + Assert.Equal("itemsViewed", Assert.Single(browsedProductsRequest.Metrics).Name); + Assert.Equal( + new[] { "view_item" }, + browsedProductsRequest.DimensionFilter.AndGroup.Expressions[0].Filter.InListFilter.Values); + } + + [Fact] + public async Task CheckCompatibilityAsync_MapsIncompatibleFieldsIntersectedWithRequested() + { + SetupSettings(CreateGoogleSettings()); + + var response = new CheckCompatibilityResponse(); + response.DimensionCompatibilities.Add(new DimensionCompatibility + { + Compatibility = Compatibility.Incompatible, + DimensionMetadata = new DimensionMetadata { ApiName = "customUser:organization_id" }, + }); + response.DimensionCompatibilities.Add(new DimensionCompatibility + { + Compatibility = Compatibility.Incompatible, + DimensionMetadata = new DimensionMetadata { ApiName = "unrelatedDimension" }, + }); + response.MetricCompatibilities.Add(new MetricCompatibility + { + Compatibility = Compatibility.Incompatible, + MetricMetadata = new MetricMetadata { ApiName = "itemsViewed" }, + }); + SetupClient(response); + var service = CreateService(); + + var result = await service.CheckCompatibilityAsync(StoreId); + + var searchTerms = result.Reports.First(x => x.Report == "searchTerms"); + Assert.False(searchTerms.Compatible); + Assert.Equal(new[] { "customUser:organization_id" }, searchTerms.IncompatibleDimensions); + Assert.Empty(searchTerms.IncompatibleMetrics); + + var browsedProducts = result.Reports.First(x => x.Report == "browsedProducts"); + Assert.False(browsedProducts.Compatible); + Assert.Equal(new[] { "customUser:organization_id" }, browsedProducts.IncompatibleDimensions); + Assert.Equal(new[] { "itemsViewed" }, browsedProducts.IncompatibleMetrics); + } + + [Fact] + public async Task CheckCompatibilityAsync_ClientThrows_SetsReportErrorMessageInsteadOfFailing() + { + SetupSettings(CreateGoogleSettings()); + _reportClientMock + .Setup(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("credentials missing")); + var service = CreateService(); + + var result = await service.CheckCompatibilityAsync(StoreId); + + Assert.True(result.Available); + Assert.Equal(2, result.Reports.Count); + Assert.All(result.Reports, x => + { + Assert.False(x.Compatible); + Assert.Equal("credentials missing", x.ErrorMessage); + }); + } + + private AnalyticsCompatibilityService CreateService() + { + return new AnalyticsCompatibilityService( + _settingsResolverMock.Object, + new GoogleAnalyticsDataSource(_reportClientMock.Object), + _reportClientMock.Object); + } + + private void SetupSettings(AnalyticsDataApiSettings settings) + { + _settingsResolverMock + .Setup(x => x.ResolveAsync(StoreId)) + .ReturnsAsync(settings); + } + + private void SetupClient(CheckCompatibilityResponse response) + { + _reportClientMock + .Setup(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny())) + .Callback((string _, CheckCompatibilityRequest request) => _capturedRequests.Add(request)) + .ReturnsAsync(response); + } + + private static AnalyticsDataApiSettings CreateGoogleSettings() + { + return new AnalyticsDataApiSettings { PropertyId = "123456", CredentialJson = "{}" }; + } +} diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs new file mode 100644 index 0000000..fe80237 --- /dev/null +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; +using VirtoCommerce.Platform.Caching; +using VirtoCommerce.Platform.Core.Caching; +using Xunit; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; + +public class AnalyticsServiceTests +{ + private const string StoreId = "test-store"; + + private static readonly DateTime To = new(2026, 8, 25, 12, 0, 0, DateTimeKind.Utc); + + private readonly Mock _settingsResolverMock = new(); + private readonly Mock _reportClientMock = new(); + private readonly Mock _googleDataSourceMock; + + public AnalyticsServiceTests() + { + _googleDataSourceMock = new Mock(_reportClientMock.Object); + } + + [Theory] + [InlineData("123456", null, false, true)] + [InlineData("123456", "{}", false, true)] + [InlineData(null, "{}", false, false)] + [InlineData(null, null, true, true)] + [InlineData(null, null, false, false)] + public async Task IsConfiguredAsync_Matrix(string propertyId, string credentialJson, bool sampleDataEnabled, bool expected) + { + var service = CreateService(new AnalyticsDataApiSettings + { + PropertyId = propertyId, + CredentialJson = credentialJson, + SampleDataEnabled = sampleDataEnabled, + }); + + Assert.Equal(expected, await service.IsConfiguredAsync(StoreId)); + } + + [Fact] + public async Task IsConfiguredAsync_ResolverThrows_ReturnsFalse() + { + _settingsResolverMock + .Setup(x => x.ResolveAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + var service = CreateService(); + + Assert.False(await service.IsConfiguredAsync(StoreId)); + } + + [Fact] + public async Task SearchEventsAsync_NotConfigured_ReturnsEmptyWithoutQueryingSource() + { + var service = CreateService(new AnalyticsDataApiSettings()); + + var result = await service.SearchEventsAsync(CreateSearchCriteria()); + + Assert.Equal(0, result.TotalCount); + Assert.Empty(result.Events); + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SearchEventsAsync_SameCriteria_UsesCache() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateGoogleConfiguredService(); + + var first = await service.SearchEventsAsync(CreateSearchCriteria()); + var second = await service.SearchEventsAsync(CreateSearchCriteria()); + + Assert.Equal(1, first.TotalCount); + Assert.Equal(1, second.TotalCount); + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task SearchEventsAsync_DifferentCriteria_QueriesSourceAgain() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateGoogleConfiguredService(); + + await service.SearchEventsAsync(CreateSearchCriteria()); + var otherCriteria = CreateSearchCriteria(); + otherCriteria.SortBy = ModuleConstants.SortBy.Count; + await service.SearchEventsAsync(otherCriteria); + + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task SearchEventsAsync_ReturnsClonedResult() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateGoogleConfiguredService(); + + var first = await service.SearchEventsAsync(CreateSearchCriteria()); + var second = await service.SearchEventsAsync(CreateSearchCriteria()); + + Assert.NotSame(first, second); + Assert.NotSame(first.Events[0], second.Events[0]); + } + + [Fact] + public async Task SearchEventsAsync_PropagatesCriteriaToQuery() + { + AnalyticsDataQuery capturedQuery = null; + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .Callback((AnalyticsDataQuery query) => capturedQuery = query) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateGoogleConfiguredService(); + + var criteria = CreateSearchCriteria(); + criteria.SortBy = ModuleConstants.SortBy.Count; + criteria.DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm }; + criteria.Skip = 5; + await service.SearchEventsAsync(criteria); + + Assert.Equal(ModuleConstants.SortBy.Count, capturedQuery.SortBy); + Assert.Equal(criteria.DimensionNames, capturedQuery.DimensionNames); + Assert.Equal("123456", capturedQuery.PropertyId); + Assert.Equal("{}", capturedQuery.CredentialJson); + Assert.Equal(20, capturedQuery.Take); + Assert.Equal(5, capturedQuery.Skip); + } + + [Fact] + public async Task SearchEventsAsync_SourceFails_CachesEmptyResultForFailureTtl() + { + _googleDataSourceMock + .SetupSequence(x => x.GetRowsAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("GA responded 400")) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateGoogleConfiguredService(); + + var failed = await service.SearchEventsAsync(CreateSearchCriteria()); + var cached = await service.SearchEventsAsync(CreateSearchCriteria()); + + Assert.Equal(0, failed.TotalCount); + Assert.Empty(failed.Events); + Assert.Equal(0, cached.TotalCount); + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task SearchEventsAsync_FailureCacheExpires_RecoversQuickly() + { + _googleDataSourceMock + .SetupSequence(x => x.GetRowsAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("GA responded 400")) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateGoogleConfiguredService(failureCacheTtl: TimeSpan.FromMilliseconds(100)); + + var failed = await service.SearchEventsAsync(CreateSearchCriteria()); + await Task.Delay(500, TestContext.Current.CancellationToken); + var recovered = await service.SearchEventsAsync(CreateSearchCriteria()); + + Assert.Equal(0, failed.TotalCount); + Assert.Equal(1, recovered.TotalCount); + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task SearchEventsAsync_SampleDataEnabled_UsesSampleSource() + { + var service = CreateService(new AnalyticsDataApiSettings { SampleDataEnabled = true }); + + var criteria = CreateSearchCriteria(); + criteria.Take = 50; + var result = await service.SearchEventsAsync(criteria); + + Assert.True(result.TotalCount > 0); + Assert.NotEmpty(result.Events); + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetEventSummariesAsync_AggregatesPerRequestedEventName() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult( + ("search", To.AddHours(-2), 2), + ("search", To, 3), + ("login", To.AddHours(-1), 5))); + var service = CreateGoogleConfiguredService(); + + var criteria = CreateSummaryCriteria( + ModuleConstants.EventNames.Search, + ModuleConstants.EventNames.Login, + ModuleConstants.EventNames.SignUp); + var summaries = await service.GetEventSummariesAsync(criteria); + + Assert.Equal(3, summaries.Count); + + var search = summaries.First(x => x.EventName == ModuleConstants.EventNames.Search); + Assert.Equal(5, search.TotalCount); + Assert.Equal(To, search.LastOccurredAt); + + var login = summaries.First(x => x.EventName == ModuleConstants.EventNames.Login); + Assert.Equal(5, login.TotalCount); + Assert.Equal(To.AddHours(-1), login.LastOccurredAt); + + var signUp = summaries.First(x => x.EventName == ModuleConstants.EventNames.SignUp); + Assert.Equal(0, signUp.TotalCount); + Assert.Null(signUp.LastOccurredAt); + } + + [Fact] + public async Task GetEventSummariesAsync_NotConfigured_ReturnsZeroSummariesPerRequestedName() + { + var service = CreateService(new AnalyticsDataApiSettings()); + + var summaries = await service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Login)); + + var summary = Assert.Single(summaries); + Assert.Equal(ModuleConstants.EventNames.Login, summary.EventName); + Assert.Equal(0, summary.TotalCount); + Assert.Null(summary.LastOccurredAt); + } + + [Fact] + public async Task GetEventSummariesAsync_SourceFails_CachesZeroSummariesForFailureTtl() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("GA responded 400")); + var service = CreateGoogleConfiguredService(); + + var failed = await service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Login)); + var cached = await service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Login)); + + Assert.Equal(0, Assert.Single(failed).TotalCount); + Assert.Equal(0, Assert.Single(cached).TotalCount); + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); + } + + private AnalyticsService CreateService(AnalyticsDataApiSettings settings = null, TimeSpan? failureCacheTtl = null) + { + if (settings != null) + { + _settingsResolverMock + .Setup(x => x.ResolveAsync(StoreId)) + .ReturnsAsync(settings); + } + + var memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + var platformMemoryCache = new PlatformMemoryCache(memoryCache, Options.Create(new CachingOptions()), new Mock>().Object); + var logger = new Mock>().Object; + + return failureCacheTtl == null + ? new AnalyticsService(_settingsResolverMock.Object, platformMemoryCache, _googleDataSourceMock.Object, new SampleAnalyticsDataSource(), logger) + : new ShortFailureTtlAnalyticsService(failureCacheTtl.Value, _settingsResolverMock.Object, platformMemoryCache, _googleDataSourceMock.Object, new SampleAnalyticsDataSource(), logger); + } + + private AnalyticsService CreateGoogleConfiguredService(TimeSpan? failureCacheTtl = null) + { + return CreateService(new AnalyticsDataApiSettings { PropertyId = "123456", CredentialJson = "{}" }, failureCacheTtl); + } + + private static AnalyticsEventSearchCriteria CreateSearchCriteria() + { + return new AnalyticsEventSearchCriteria + { + StoreId = StoreId, + EventNames = new List { ModuleConstants.EventNames.Search }, + To = To, + Take = 20, + }; + } + + private static AnalyticsEventSummaryCriteria CreateSummaryCriteria(params string[] eventNames) + { + return new AnalyticsEventSummaryCriteria + { + StoreId = StoreId, + EventNames = eventNames.ToList(), + To = To, + }; + } + + private static AnalyticsEventSearchResult CreateSearchResult(params (string EventName, DateTime OccurredAt, int Count)[] events) + { + return new AnalyticsEventSearchResult + { + TotalCount = events.Length, + Events = events + .Select(x => new AnalyticsEvent { EventName = x.EventName, OccurredAt = x.OccurredAt, Count = x.Count }) + .ToList(), + }; + } + + private sealed class ShortFailureTtlAnalyticsService : AnalyticsService + { + private readonly TimeSpan _failureCacheTtl; + + public ShortFailureTtlAnalyticsService( + TimeSpan failureCacheTtl, + IAnalyticsSettingsResolver settingsResolver, + IPlatformMemoryCache platformMemoryCache, + GoogleAnalyticsDataSource googleAnalyticsDataSource, + SampleAnalyticsDataSource sampleAnalyticsDataSource, + ILogger logger) + : base(settingsResolver, platformMemoryCache, googleAnalyticsDataSource, sampleAnalyticsDataSource, logger) + { + _failureCacheTtl = failureCacheTtl; + } + + protected override TimeSpan FailureCacheTtl => _failureCacheTtl; + } +} diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs new file mode 100644 index 0000000..e4c7ce3 --- /dev/null +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Moq; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; +using VirtoCommerce.Platform.Core.Settings; +using VirtoCommerce.StoreModule.Core.Model; +using VirtoCommerce.StoreModule.Core.Services; +using Xunit; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; + +public class AnalyticsSettingsResolverTests +{ + private const string StoreId = "test-store"; + + private readonly Mock _storeServiceMock = new(); + private readonly Mock _settingsManagerMock = new(); + + public AnalyticsSettingsResolverTests() + { + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync(It.IsAny(), null, null)) + .ReturnsAsync((string name, string _, string _) => new ObjectSettingEntry { Name = name }); + } + + [Fact] + public async Task ResolveAsync_MapsStoreSettings() + { + SetupStore(CreateStore( + (ModuleConstants.Settings.DataApi.PropertyId.Name, "123456"), + (ModuleConstants.Settings.DataApi.ServiceAccountJson.Name, "{}"), + (ModuleConstants.Settings.DataApi.CacheTtlMinutes.Name, 15), + (ModuleConstants.Settings.DataApi.SampleDataEnabled.Name, true))); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(StoreId); + + Assert.Equal("123456", settings.PropertyId); + Assert.Equal("{}", settings.CredentialJson); + Assert.Equal(15, settings.CacheTtlMinutes); + Assert.True(settings.SampleDataEnabled); + Assert.True(settings.IsGoogleConfigured); + } + + [Fact] + public async Task ResolveAsync_StoreValueWinsOverGlobal() + { + SetupGlobalSetting(ModuleConstants.Settings.DataApi.PropertyId.Name, "global-property"); + SetupStore(CreateStore((ModuleConstants.Settings.DataApi.PropertyId.Name, "store-property"))); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(StoreId); + + Assert.Equal("store-property", settings.PropertyId); + } + + [Fact] + public async Task ResolveAsync_FallsBackToGlobalSettings() + { + SetupGlobalSetting(ModuleConstants.Settings.DataApi.PropertyId.Name, "global-property"); + SetupStore(CreateStore()); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(StoreId); + + Assert.Equal("global-property", settings.PropertyId); + } + + [Fact] + public async Task ResolveAsync_NullStoreId_UsesGlobalSettingsWithoutLoadingStore() + { + SetupGlobalSetting(ModuleConstants.Settings.DataApi.PropertyId.Name, "global-property"); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(null); + + Assert.Equal("global-property", settings.PropertyId); + _storeServiceMock.Verify(x => x.GetAsync(It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ResolveAsync_NothingConfigured_ReturnsUnconfiguredSettings() + { + SetupStore(CreateStore()); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(StoreId); + + Assert.Null(settings.PropertyId); + Assert.Null(settings.CredentialJson); + Assert.False(settings.SampleDataEnabled); + Assert.False(settings.IsGoogleConfigured); + } + + private AnalyticsSettingsResolver CreateResolver() + { + return new AnalyticsSettingsResolver(_storeServiceMock.Object, _settingsManagerMock.Object); + } + + private void SetupStore(Store store) + { + _storeServiceMock + .Setup(x => x.GetAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IList ids, string _, bool _) => + ids.Contains(store.Id) ? new List { store } : new List()); + } + + private void SetupGlobalSetting(string name, object value) + { + _settingsManagerMock + .Setup(x => x.GetObjectSettingAsync(name, null, null)) + .ReturnsAsync(new ObjectSettingEntry { Name = name, Value = value }); + } + + private static Store CreateStore(params (string Name, object Value)[] settings) + { + return new Store + { + Id = StoreId, + Settings = settings.Select(x => new ObjectSettingEntry { Name = x.Name, Value = x.Value }).ToList(), + }; + } +} diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs new file mode 100644 index 0000000..5cc793a --- /dev/null +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Google.Analytics.Data.V1Beta; +using Moq; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; +using Xunit; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; + +public class GoogleAnalyticsDataSourceTests +{ + private readonly Mock _reportClientMock = new(); + + private string _capturedCredential; + private RunReportRequest _capturedRequest; + + [Fact] + public async Task GetRowsAsync_BuildsRunReportRequest() + { + var dataSource = CreateDataSource(); + var query = new AnalyticsDataQuery + { + PropertyId = "123456", + CredentialJson = "{}", + EventNames = new List { ModuleConstants.EventNames.Search }, + DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm, ModuleConstants.UserDimensions.OrganizationId }, + DimensionFilters = new List + { + new() { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = new List { "org1", "org2" } }, + new() { DimensionName = ModuleConstants.Dimensions.SearchTerm, Values = new List { "drill" } }, + }, + From = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), + To = new DateTime(2026, 8, 25, 0, 0, 0, DateTimeKind.Utc), + Take = 10, + Skip = 5, + }; + + await dataSource.GetRowsAsync(query); + + Assert.Equal("{}", _capturedCredential); + Assert.Equal("properties/123456", _capturedRequest.Property); + Assert.Equal(10L, _capturedRequest.Limit); + Assert.Equal(5L, _capturedRequest.Offset); + + Assert.Equal( + new[] { "eventName", "dateHour", "searchTerm", "customUser:organization_id" }, + _capturedRequest.Dimensions.Select(x => x.Name)); + Assert.Equal("eventCount", Assert.Single(_capturedRequest.Metrics).Name); + + var dateRange = Assert.Single(_capturedRequest.DateRanges); + Assert.Equal("2026-08-01", dateRange.StartDate); + Assert.Equal("2026-08-25", dateRange.EndDate); + + var expressions = _capturedRequest.DimensionFilter.AndGroup.Expressions; + Assert.Equal(3, expressions.Count); + Assert.Equal("eventName", expressions[0].Filter.FieldName); + Assert.Equal(new[] { "search" }, expressions[0].Filter.InListFilter.Values); + Assert.Equal("customUser:organization_id", expressions[1].Filter.FieldName); + Assert.Equal(new[] { "org1", "org2" }, expressions[1].Filter.InListFilter.Values); + Assert.Equal("searchTerm", expressions[2].Filter.FieldName); + + var orderBy = Assert.Single(_capturedRequest.OrderBys); + Assert.True(orderBy.Desc); + Assert.Equal("dateHour", orderBy.Dimension.DimensionName); + } + + [Fact] + public async Task GetRowsAsync_NoFromTo_UsesDefaultDateRange() + { + var dataSource = CreateDataSource(); + + await dataSource.GetRowsAsync(new AnalyticsDataQuery { PropertyId = "123456", Take = 10 }); + + var dateRange = Assert.Single(_capturedRequest.DateRanges); + Assert.Equal("2015-08-14", dateRange.StartDate); + Assert.Equal("today", dateRange.EndDate); + } + + [Fact] + public async Task GetRowsAsync_SingleFilter_DoesNotWrapInAndGroup() + { + var dataSource = CreateDataSource(); + + await dataSource.GetRowsAsync(new AnalyticsDataQuery + { + PropertyId = "123456", + EventNames = new List { ModuleConstants.EventNames.Login }, + Take = 10, + }); + + Assert.Null(_capturedRequest.DimensionFilter.AndGroup); + Assert.Equal("eventName", _capturedRequest.DimensionFilter.Filter.FieldName); + } + + [Fact] + public async Task GetRowsAsync_NoFilters_LeavesDimensionFilterEmpty() + { + var dataSource = CreateDataSource(); + + await dataSource.GetRowsAsync(new AnalyticsDataQuery { PropertyId = "123456", Take = 10 }); + + Assert.Null(_capturedRequest.DimensionFilter); + } + + [Fact] + public async Task GetRowsAsync_MapsRowsAndNormalizesNotSet() + { + var response = new RunReportResponse { RowCount = 42 }; + response.DimensionHeaders.Add(new DimensionHeader { Name = "eventName" }); + response.DimensionHeaders.Add(new DimensionHeader { Name = "dateHour" }); + response.DimensionHeaders.Add(new DimensionHeader { Name = "searchTerm" }); + response.DimensionHeaders.Add(new DimensionHeader { Name = "customUser:organization_id" }); + + var row = new Row(); + row.DimensionValues.Add(new DimensionValue { Value = "search" }); + row.DimensionValues.Add(new DimensionValue { Value = "2026082510" }); + row.DimensionValues.Add(new DimensionValue { Value = "drill" }); + row.DimensionValues.Add(new DimensionValue { Value = "(not set)" }); + row.MetricValues.Add(new MetricValue { Value = "7" }); + response.Rows.Add(row); + + var dataSource = CreateDataSource(response); + + var result = await dataSource.GetRowsAsync(new AnalyticsDataQuery { PropertyId = "123456", Take = 10 }); + + Assert.Equal(42, result.TotalCount); + + var analyticsEvent = Assert.Single(result.Events); + Assert.Equal("search", analyticsEvent.EventName); + Assert.Equal(new DateTime(2026, 8, 25, 10, 0, 0, DateTimeKind.Utc), analyticsEvent.OccurredAt); + Assert.Equal(DateTimeKind.Utc, analyticsEvent.OccurredAt.Value.Kind); + Assert.Equal(7, analyticsEvent.Count); + Assert.Equal("drill", analyticsEvent.Dimensions["searchTerm"]); + Assert.False(analyticsEvent.Dimensions.ContainsKey("organization_id")); + } + + [Fact] + public async Task GetRowsAsync_UserDimensionPrefixStrippedInRows() + { + var response = new RunReportResponse { RowCount = 1 }; + response.DimensionHeaders.Add(new DimensionHeader { Name = "eventName" }); + response.DimensionHeaders.Add(new DimensionHeader { Name = "dateHour" }); + response.DimensionHeaders.Add(new DimensionHeader { Name = "customUser:organization_id" }); + + var row = new Row(); + row.DimensionValues.Add(new DimensionValue { Value = "login" }); + row.DimensionValues.Add(new DimensionValue { Value = "2026082510" }); + row.DimensionValues.Add(new DimensionValue { Value = "org1" }); + row.MetricValues.Add(new MetricValue { Value = "1" }); + response.Rows.Add(row); + + var dataSource = CreateDataSource(response); + + var result = await dataSource.GetRowsAsync(new AnalyticsDataQuery { PropertyId = "123456", Take = 10 }); + + Assert.Equal("org1", Assert.Single(result.Events).Dimensions["organization_id"]); + } + + [Fact] + public async Task GetRowsAsync_TakeZero_ReturnsCountOnly() + { + var response = new RunReportResponse { RowCount = 42 }; + response.DimensionHeaders.Add(new DimensionHeader { Name = "eventName" }); + response.DimensionHeaders.Add(new DimensionHeader { Name = "dateHour" }); + + var row = new Row(); + row.DimensionValues.Add(new DimensionValue { Value = "search" }); + row.DimensionValues.Add(new DimensionValue { Value = "2026082510" }); + row.MetricValues.Add(new MetricValue { Value = "1" }); + response.Rows.Add(row); + + var dataSource = CreateDataSource(response); + + var result = await dataSource.GetRowsAsync(new AnalyticsDataQuery { PropertyId = "123456", Take = 0 }); + + Assert.Equal(1L, _capturedRequest.Limit); + Assert.Equal(42, result.TotalCount); + Assert.Empty(result.Events); + } + + [Fact] + public async Task GetRowsAsync_CountSort_OmitsDateHourAndOrdersByMetric() + { + var dataSource = CreateDataSource(); + + await dataSource.GetRowsAsync(new AnalyticsDataQuery + { + PropertyId = "123456", + EventNames = new List { ModuleConstants.EventNames.Search }, + DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm }, + SortBy = ModuleConstants.SortBy.Count, + Take = 10, + }); + + Assert.Equal(new[] { "eventName", "searchTerm" }, _capturedRequest.Dimensions.Select(x => x.Name)); + Assert.Equal("eventCount", Assert.Single(_capturedRequest.Metrics).Name); + + var orderBy = Assert.Single(_capturedRequest.OrderBys); + Assert.True(orderBy.Desc); + Assert.Null(orderBy.Dimension); + Assert.Equal("eventCount", orderBy.Metric.MetricName); + } + + [Fact] + public async Task GetRowsAsync_ItemDimensions_BuildsItemReportRequest() + { + var dataSource = CreateDataSource(); + + await dataSource.GetRowsAsync(new AnalyticsDataQuery + { + PropertyId = "123456", + EventNames = new List { ModuleConstants.EventNames.ViewItem }, + DimensionNames = new List { ModuleConstants.Dimensions.ItemId, ModuleConstants.Dimensions.ItemName }, + DimensionFilters = new List + { + new() { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = new List { "org1" } }, + }, + Take = 10, + }); + + Assert.Equal(new[] { "dateHour", "itemId", "itemName" }, _capturedRequest.Dimensions.Select(x => x.Name)); + Assert.Equal("itemsViewed", Assert.Single(_capturedRequest.Metrics).Name); + + var expressions = _capturedRequest.DimensionFilter.AndGroup.Expressions; + Assert.Equal(2, expressions.Count); + Assert.Equal("eventName", expressions[0].Filter.FieldName); + Assert.Equal(new[] { "view_item" }, expressions[0].Filter.InListFilter.Values); + Assert.Equal("customUser:organization_id", expressions[1].Filter.FieldName); + + var orderBy = Assert.Single(_capturedRequest.OrderBys); + Assert.True(orderBy.Desc); + Assert.Equal("dateHour", orderBy.Dimension.DimensionName); + } + + [Fact] + public async Task GetRowsAsync_ItemDimensionsCountSort_OmitsDateHourAndOrdersByItemsViewed() + { + var dataSource = CreateDataSource(); + + await dataSource.GetRowsAsync(new AnalyticsDataQuery + { + PropertyId = "123456", + EventNames = new List { ModuleConstants.EventNames.ViewItem }, + DimensionNames = new List { ModuleConstants.Dimensions.ItemId, ModuleConstants.Dimensions.ItemName }, + SortBy = ModuleConstants.SortBy.Count, + Take = 10, + }); + + Assert.Equal(new[] { "itemId", "itemName" }, _capturedRequest.Dimensions.Select(x => x.Name)); + Assert.Equal("itemsViewed", Assert.Single(_capturedRequest.Metrics).Name); + + var orderBy = Assert.Single(_capturedRequest.OrderBys); + Assert.True(orderBy.Desc); + Assert.Equal("itemsViewed", orderBy.Metric.MetricName); + } + + [Fact] + public async Task GetRowsAsync_ItemRows_SingleEventName_FillsEventNameFromCriteria() + { + var response = new RunReportResponse { RowCount = 1 }; + response.DimensionHeaders.Add(new DimensionHeader { Name = "dateHour" }); + response.DimensionHeaders.Add(new DimensionHeader { Name = "itemId" }); + + var row = new Row(); + row.DimensionValues.Add(new DimensionValue { Value = "2026082510" }); + row.DimensionValues.Add(new DimensionValue { Value = "SKU-001" }); + row.MetricValues.Add(new MetricValue { Value = "9" }); + response.Rows.Add(row); + + var dataSource = CreateDataSource(response); + + var result = await dataSource.GetRowsAsync(new AnalyticsDataQuery + { + PropertyId = "123456", + EventNames = new List { ModuleConstants.EventNames.ViewItem }, + DimensionNames = new List { ModuleConstants.Dimensions.ItemId }, + Take = 10, + }); + + var analyticsEvent = Assert.Single(result.Events); + Assert.Equal("view_item", analyticsEvent.EventName); + Assert.Equal(new DateTime(2026, 8, 25, 10, 0, 0, DateTimeKind.Utc), analyticsEvent.OccurredAt); + Assert.Equal(9, analyticsEvent.Count); + Assert.Equal("SKU-001", analyticsEvent.Dimensions["itemId"]); + } + + [Fact] + public async Task GetRowsAsync_ItemRows_MultipleEventNames_LeavesEventNameNull() + { + var response = new RunReportResponse { RowCount = 1 }; + response.DimensionHeaders.Add(new DimensionHeader { Name = "itemId" }); + + var row = new Row(); + row.DimensionValues.Add(new DimensionValue { Value = "SKU-001" }); + row.MetricValues.Add(new MetricValue { Value = "9" }); + response.Rows.Add(row); + + var dataSource = CreateDataSource(response); + + var result = await dataSource.GetRowsAsync(new AnalyticsDataQuery + { + PropertyId = "123456", + EventNames = new List { ModuleConstants.EventNames.ViewItem, ModuleConstants.EventNames.AddToCart }, + DimensionNames = new List { ModuleConstants.Dimensions.ItemId }, + Take = 10, + }); + + Assert.Null(Assert.Single(result.Events).EventName); + } + + private GoogleAnalyticsDataSource CreateDataSource(RunReportResponse response = null) + { + _reportClientMock + .Setup(x => x.RunReportAsync(It.IsAny(), It.IsAny())) + .Callback((string credential, RunReportRequest request) => + { + _capturedCredential = credential; + _capturedRequest = request; + }) + .ReturnsAsync(response ?? new RunReportResponse()); + + return new GoogleAnalyticsDataSource(_reportClientMock.Object); + } +} diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs new file mode 100644 index 0000000..eb20475 --- /dev/null +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs @@ -0,0 +1,99 @@ +using System.Security.Cryptography; +using System.Threading.Tasks; +using Google.Apis.Auth.OAuth2; +using Newtonsoft.Json; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; +using Xunit; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; + +public class GoogleAnalyticsReportClientTests +{ + private const string AnalyticsReadOnlyScope = "https://www.googleapis.com/auth/analytics.readonly"; + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ResolveCredentialAsync_NoJson_UsesApplicationDefaultCredentials(string credentialJson) + { + var client = new TestableReportClient(); + + var credential = await client.ResolveAsync(credentialJson); + + Assert.Equal(1, client.AdcCallCount); + Assert.IsType(credential.UnderlyingCredential); + Assert.Contains(AnalyticsReadOnlyScope, ((ServiceAccountCredential)credential.UnderlyingCredential).Scopes); + } + + [Fact] + public async Task ResolveCredentialAsync_ServiceAccountJson_CreatesScopedServiceAccountCredential() + { + var client = new TestableReportClient(); + + var credential = await client.ResolveAsync(CreateServiceAccountJson()); + + Assert.Equal(0, client.AdcCallCount); + var serviceAccountCredential = Assert.IsType(credential.UnderlyingCredential); + Assert.Contains(AnalyticsReadOnlyScope, serviceAccountCredential.Scopes); + Assert.False(credential.IsCreateScopedRequired); + } + + [Fact] + public async Task ResolveCredentialAsync_ExternalAccountJson_CreatesWorkloadIdentityFederationCredential() + { + var client = new TestableReportClient(); + + var credential = await client.ResolveAsync(CreateExternalAccountJson()); + + Assert.Equal(0, client.AdcCallCount); + Assert.NotNull(credential.UnderlyingCredential); + Assert.Contains("ExternalAccountCredential", credential.UnderlyingCredential.GetType().Name); + Assert.False(credential.IsCreateScopedRequired); + } + + private static string CreateServiceAccountJson() + { + using var rsa = RSA.Create(2048); + var privateKey = PemEncoding.WriteString("PRIVATE KEY", rsa.ExportPkcs8PrivateKey()); + + return JsonConvert.SerializeObject(new + { + type = "service_account", + project_id = "test-project", + private_key_id = "key1", + private_key = privateKey, + client_email = "probe@test-project.iam.gserviceaccount.com", + client_id = "1", + token_uri = "https://oauth2.googleapis.com/token", + }); + } + + private static string CreateExternalAccountJson() + { + return JsonConvert.SerializeObject(new + { + type = "external_account", + audience = "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/azure", + subject_token_type = "urn:ietf:params:oauth:token-type:jwt", + token_url = "https://sts.googleapis.com/v1/token", + credential_source = new { url = "https://login.example.com/token" }, + }); + } + + private sealed class TestableReportClient : GoogleAnalyticsReportClient + { + public int AdcCallCount { get; private set; } + + public Task ResolveAsync(string credentialJson) + { + return ResolveCredentialAsync(credentialJson); + } + + protected override Task GetApplicationDefaultCredentialAsync() + { + AdcCallCount++; + return Task.FromResult(CredentialFactory.FromJson(CreateServiceAccountJson(), JsonCredentialParameters.ServiceAccountCredentialType)); + } + } +} diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/HelloWorldTest.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/HelloWorldTest.cs deleted file mode 100644 index f14adc3..0000000 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/HelloWorldTest.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Xunit; - -namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests -{ - public class HelloWorldTest - { - [Fact] - public void HelloWorld() - { - } - } -} diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/SampleAnalyticsDataSourceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/SampleAnalyticsDataSourceTests.cs new file mode 100644 index 0000000..72f47d1 --- /dev/null +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/SampleAnalyticsDataSourceTests.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; +using Xunit; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; + +public class SampleAnalyticsDataSourceTests +{ + private static readonly DateTime To = new(2026, 8, 25, 12, 0, 0, DateTimeKind.Utc); + + private readonly SampleAnalyticsDataSource _dataSource = new(); + + [Fact] + public async Task GetRowsAsync_SameQuery_ReturnsIdenticalData() + { + var first = await _dataSource.GetRowsAsync(CreateQuery()); + var second = await _dataSource.GetRowsAsync(CreateQuery()); + + Assert.True(first.TotalCount > 0); + Assert.Equal(first.TotalCount, second.TotalCount); + Assert.Equal(first.Events.Select(Describe), second.Events.Select(Describe)); + } + + [Fact] + public async Task GetRowsAsync_HonorsEventNames() + { + var query = CreateQuery(); + query.EventNames = new List { ModuleConstants.EventNames.Login }; + + var result = await _dataSource.GetRowsAsync(query); + + Assert.NotEmpty(result.Events); + Assert.All(result.Events, x => Assert.Equal(ModuleConstants.EventNames.Login, x.EventName)); + } + + [Fact] + public async Task GetRowsAsync_HonorsDimensionFilterValues() + { + var query = CreateQuery(); + query.DimensionNames = new List { ModuleConstants.UserDimensions.OrganizationId }; + query.DimensionFilters = new List + { + new() { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = new List { "org1" } }, + }; + + var result = await _dataSource.GetRowsAsync(query); + + Assert.NotEmpty(result.Events); + Assert.All(result.Events, x => Assert.Equal("org1", x.Dimensions[ModuleConstants.UserDimensions.OrganizationId])); + } + + [Fact] + public async Task GetRowsAsync_DifferentFilters_ProduceDifferentSeries() + { + var org1Query = CreateQuery(); + org1Query.DimensionFilters = new List + { + new() { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = new List { "org1" } }, + }; + + var org2Query = CreateQuery(); + org2Query.DimensionFilters = new List + { + new() { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = new List { "org2" } }, + }; + + var org1Result = await _dataSource.GetRowsAsync(org1Query); + var org2Result = await _dataSource.GetRowsAsync(org2Query); + + Assert.NotEqual(org1Result.Events.Select(Describe), org2Result.Events.Select(Describe)); + } + + [Fact] + public async Task GetRowsAsync_HonorsFromToBounds() + { + var query = CreateQuery(); + query.From = To.AddHours(-5); + + var result = await _dataSource.GetRowsAsync(query); + + Assert.NotEmpty(result.Events); + Assert.All(result.Events, x => + { + Assert.True(x.OccurredAt >= query.From); + Assert.True(x.OccurredAt <= To); + }); + } + + [Fact] + public async Task GetRowsAsync_PagingSlicesWithoutChangingTotalCount() + { + var fullResult = await _dataSource.GetRowsAsync(CreateQuery()); + + var pageQuery = CreateQuery(); + pageQuery.Skip = 5; + pageQuery.Take = 10; + var pageResult = await _dataSource.GetRowsAsync(pageQuery); + + Assert.Equal(fullResult.TotalCount, pageResult.TotalCount); + Assert.Equal(10, pageResult.Events.Count); + Assert.Equal(fullResult.Events.Skip(5).Take(10).Select(Describe), pageResult.Events.Select(Describe)); + } + + [Fact] + public async Task GetRowsAsync_TakeZero_ReturnsCountOnly() + { + var query = CreateQuery(); + query.Take = 0; + + var result = await _dataSource.GetRowsAsync(query); + + Assert.True(result.TotalCount > 0); + Assert.Empty(result.Events); + } + + [Fact] + public async Task GetRowsAsync_SortsByOccurredAtDescending() + { + var result = await _dataSource.GetRowsAsync(CreateQuery()); + + var occurredAts = result.Events.Select(x => x.OccurredAt).ToList(); + Assert.Equal(occurredAts.OrderByDescending(x => x), occurredAts); + } + + [Fact] + public async Task GetRowsAsync_CountSort_AggregatesPerDimensionTuple() + { + var dateQuery = CreateQuery(); + dateQuery.DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm }; + + var countQuery = CreateQuery(); + countQuery.DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm }; + countQuery.SortBy = ModuleConstants.SortBy.Count; + + var dateResult = await _dataSource.GetRowsAsync(dateQuery); + var countResult = await _dataSource.GetRowsAsync(countQuery); + + Assert.NotEmpty(countResult.Events); + Assert.True(countResult.TotalCount < dateResult.TotalCount); + Assert.Equal(dateResult.Events.Sum(x => x.Count), countResult.Events.Sum(x => x.Count)); + Assert.All(countResult.Events, x => Assert.Null(x.OccurredAt)); + + var tuples = countResult.Events.Select(x => $"{x.EventName}|{x.Dimensions[ModuleConstants.Dimensions.SearchTerm]}").ToList(); + Assert.Equal(tuples.Distinct().Count(), tuples.Count); + + var counts = countResult.Events.Select(x => x.Count).ToList(); + Assert.Equal(counts.OrderByDescending(x => x), counts); + } + + [Fact] + public async Task GetRowsAsync_CountSort_IsDeterministic() + { + var first = await _dataSource.GetRowsAsync(CreateCountQuery()); + var second = await _dataSource.GetRowsAsync(CreateCountQuery()); + + Assert.True(first.TotalCount > 0); + Assert.Equal(first.Events.Select(Describe), second.Events.Select(Describe)); + } + + [Fact] + public async Task GetRowsAsync_CountSort_PagingSlicesWithoutChangingTotalCount() + { + var fullResult = await _dataSource.GetRowsAsync(CreateCountQuery()); + + var pageQuery = CreateCountQuery(); + pageQuery.Skip = 2; + pageQuery.Take = 3; + var pageResult = await _dataSource.GetRowsAsync(pageQuery); + + Assert.Equal(fullResult.TotalCount, pageResult.TotalCount); + Assert.Equal(fullResult.Events.Skip(2).Take(3).Select(Describe), pageResult.Events.Select(Describe)); + } + + private static AnalyticsDataQuery CreateCountQuery() + { + var query = CreateQuery(); + query.DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm }; + query.SortBy = ModuleConstants.SortBy.Count; + return query; + } + + private static AnalyticsDataQuery CreateQuery() + { + return new AnalyticsDataQuery + { + To = To, + Take = 10_000, + }; + } + + private static string Describe(AnalyticsEvent analyticsEvent) + { + var dimensions = string.Join(",", analyticsEvent.Dimensions + .OrderBy(x => x.Key, StringComparer.Ordinal) + .Select(x => $"{x.Key}={x.Value}")); + + return $"{analyticsEvent.EventName}|{analyticsEvent.OccurredAt:O}|{analyticsEvent.Count}|{dimensions}"; + } +} diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests.csproj b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests.csproj index 543063d..5f40277 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests.csproj +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests.csproj @@ -10,12 +10,13 @@ - + + - \ No newline at end of file + From 0b6152e83ccb158632d8554978a68768119a7798 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Tue, 25 Aug 2026 16:10:52 -0500 Subject: [PATCH 02/36] Implementation (draft) --- .../VirtoCommerce.GoogleEcommerceAnalyticsModule.Web.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web.csproj b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web.csproj index adeb2ad..c6c68d8 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web.csproj +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web.csproj @@ -2,6 +2,8 @@ net10.0 Library + + true From 27504b667f5c7a36c1cc39226e4d8b5bbd2b6d50 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 27 Aug 2026 08:47:20 -0500 Subject: [PATCH 03/36] Diagnostics --- .../Models/AnalyticsDiagnosticsCheck.cs | 12 + .../Models/AnalyticsDiagnosticsReportShape.cs | 14 + .../Models/AnalyticsDiagnosticsRequest.cs | 14 + .../Models/AnalyticsDiagnosticsResult.cs | 8 + .../ModuleConstants.cs | 19 + .../Services/IAnalyticsDiagnosticsService.cs | 9 + .../Models/AnalyticsCompatibilityResult.cs | 12 - .../Models/AnalyticsReportCompatibility.cs | 16 - .../Services/AnalyticsCompatibilityService.cs | 177 ----- .../Services/AnalyticsDiagnosticsService.cs | 623 +++++++++++++++++ .../Services/GoogleAnalyticsReportClient.cs | 17 + .../IAnalyticsCompatibilityService.cs | 9 - .../Services/IGoogleAnalyticsReportClient.cs | 6 + .../Api/GoogleAnalyticsController.cs | 16 +- .../Module.cs | 6 +- .../AnalyticsCompatibilityServiceTests.cs | 186 ----- .../AnalyticsDiagnosticsServiceTests.cs | 649 ++++++++++++++++++ 17 files changed, 1383 insertions(+), 410 deletions(-) create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsCheck.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsReportShape.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsRequest.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsResult.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsDiagnosticsService.cs delete mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsCompatibilityResult.cs delete mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsReportCompatibility.cs delete mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsCompatibilityService.cs create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs delete mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsCompatibilityService.cs delete mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsCompatibilityServiceTests.cs create mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsCheck.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsCheck.cs new file mode 100644 index 0000000..710bf91 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsCheck.cs @@ -0,0 +1,12 @@ +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +public class AnalyticsDiagnosticsCheck +{ + public string Stage { get; set; } + + public string Status { get; set; } + + public string Message { get; set; } + + public string Detail { get; set; } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsReportShape.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsReportShape.cs new file mode 100644 index 0000000..8fd904e --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsReportShape.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +public class AnalyticsDiagnosticsReportShape +{ + public string Name { get; set; } + + public IList DimensionNames { get; set; } = new List(); + + public string MetricName { get; set; } + + public IList EventNames { get; set; } = new List(); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsRequest.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsRequest.cs new file mode 100644 index 0000000..8507134 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsRequest.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +public class AnalyticsDiagnosticsRequest +{ + public IList UserDimensionNames { get; set; } = new List(); + + public IList EventNames { get; set; } = new List(); + + public IList Reports { get; set; } = new List(); + + public bool IncludeLiveData { get; set; } = true; +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsResult.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsResult.cs new file mode 100644 index 0000000..33b86c5 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsResult.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +public class AnalyticsDiagnosticsResult +{ + public IList Checks { get; set; } = new List(); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index 4cc8c3a..f8510c0 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -60,6 +60,25 @@ public static class SortBy public const string Count = "count"; } + public static class DiagnosticsStages + { + public const string Configuration = "configuration"; + public const string Credentials = "credentials"; + public const string ApiAccess = "apiAccess"; + public const string CustomDimensions = "customDimensions"; + public const string ReportCompatibility = "reportCompatibility"; + public const string Realtime = "realtime"; + public const string ProcessedData = "processedData"; + } + + public static class DiagnosticsStatuses + { + public const string Passed = "Passed"; + public const string Warning = "Warning"; + public const string Failed = "Failed"; + public const string Skipped = "Skipped"; + } + public static class Settings { public const string DefaultGoogleAnalyticsUrl = "https://analytics.google.com/analytics/web/"; diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsDiagnosticsService.cs new file mode 100644 index 0000000..f8deb04 --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsDiagnosticsService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; + +public interface IAnalyticsDiagnosticsService +{ + Task RunAsync(string storeId, AnalyticsDiagnosticsRequest request); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsCompatibilityResult.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsCompatibilityResult.cs deleted file mode 100644 index 4b13eaa..0000000 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsCompatibilityResult.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; - -namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; - -public class AnalyticsCompatibilityResult -{ - public bool Available { get; set; } - - public string ErrorMessage { get; set; } - - public IList Reports { get; set; } = new List(); -} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsReportCompatibility.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsReportCompatibility.cs deleted file mode 100644 index 3957f4d..0000000 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsReportCompatibility.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; - -namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; - -public class AnalyticsReportCompatibility -{ - public string Report { get; set; } - - public bool Compatible { get; set; } - - public IList IncompatibleDimensions { get; set; } = new List(); - - public IList IncompatibleMetrics { get; set; } = new List(); - - public string ErrorMessage { get; set; } -} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsCompatibilityService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsCompatibilityService.cs deleted file mode 100644 index 7aeea3d..0000000 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsCompatibilityService.cs +++ /dev/null @@ -1,177 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Google.Analytics.Data.V1Beta; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; -using VirtoCommerce.Platform.Core.Common; - -namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; - -public class AnalyticsCompatibilityService : IAnalyticsCompatibilityService -{ - private const string SearchTermsReport = "searchTerms"; - private const string BrowsedProductsReport = "browsedProducts"; - private const string ProbeFilterValue = "compatibility-probe"; - - private readonly IAnalyticsSettingsResolver _settingsResolver; - private readonly GoogleAnalyticsDataSource _googleAnalyticsDataSource; - private readonly IGoogleAnalyticsReportClient _reportClient; - - public AnalyticsCompatibilityService( - IAnalyticsSettingsResolver settingsResolver, - GoogleAnalyticsDataSource googleAnalyticsDataSource, - IGoogleAnalyticsReportClient reportClient) - { - _settingsResolver = settingsResolver; - _googleAnalyticsDataSource = googleAnalyticsDataSource; - _reportClient = reportClient; - } - - public virtual async Task CheckCompatibilityAsync(string storeId) - { - var result = new AnalyticsCompatibilityResult(); - - try - { - var settings = await _settingsResolver.ResolveAsync(storeId); - - if (settings.SampleDataEnabled) - { - result.ErrorMessage = "Sample data mode is enabled; the Google Analytics compatibility check does not apply to the sample data source."; - return result; - } - - if (!settings.IsGoogleConfigured) - { - result.ErrorMessage = "Google Analytics 4 Data API property is not configured for the store."; - return result; - } - - result.Available = true; - result.Reports.Add(await CheckReportAsync(SearchTermsReport, settings, - _googleAnalyticsDataSource.BuildEventReportRequest(CreateSearchTermsQuery(settings)))); - result.Reports.Add(await CheckReportAsync(BrowsedProductsReport, settings, - _googleAnalyticsDataSource.BuildItemReportRequest(CreateBrowsedProductsQuery(settings)))); - } - catch (Exception ex) - { - result.Available = false; - result.ErrorMessage = ex.Message; - } - - return result; - } - - protected virtual async Task CheckReportAsync(string reportName, AnalyticsDataApiSettings settings, RunReportRequest reportRequest) - { - var result = new AnalyticsReportCompatibility { Report = reportName }; - - try - { - var request = new CheckCompatibilityRequest - { - Property = reportRequest.Property, - CompatibilityFilter = Compatibility.Incompatible, - }; - request.Dimensions.AddRange(reportRequest.Dimensions); - request.Metrics.AddRange(reportRequest.Metrics); - if (reportRequest.DimensionFilter != null) - { - request.DimensionFilter = reportRequest.DimensionFilter; - } - - var response = await _reportClient.CheckCompatibilityAsync(settings.CredentialJson, request); - var requestedFields = GetRequestedFieldNames(reportRequest); - - result.IncompatibleDimensions = response.DimensionCompatibilities - .Where(x => x.Compatibility == Compatibility.Incompatible) - .Select(x => x.DimensionMetadata?.ApiName) - .Where(x => !string.IsNullOrEmpty(x) && requestedFields.Contains(x)) - .ToList(); - result.IncompatibleMetrics = response.MetricCompatibilities - .Where(x => x.Compatibility == Compatibility.Incompatible) - .Select(x => x.MetricMetadata?.ApiName) - .Where(x => !string.IsNullOrEmpty(x) && requestedFields.Contains(x)) - .ToList(); - result.Compatible = result.IncompatibleDimensions.Count == 0 && result.IncompatibleMetrics.Count == 0; - } - catch (Exception ex) - { - result.ErrorMessage = ex.Message; - } - - return result; - } - - protected virtual AnalyticsDataQuery CreateSearchTermsQuery(AnalyticsDataApiSettings settings) - { - return new AnalyticsDataQuery - { - PropertyId = settings.PropertyId, - CredentialJson = settings.CredentialJson, - EventNames = new List { ModuleConstants.EventNames.Search, ModuleConstants.EventNames.ViewSearchResults }, - DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm }, - DimensionFilters = CreateUserDimensionFilters(), - Take = 1, - }; - } - - protected virtual AnalyticsDataQuery CreateBrowsedProductsQuery(AnalyticsDataApiSettings settings) - { - return new AnalyticsDataQuery - { - PropertyId = settings.PropertyId, - CredentialJson = settings.CredentialJson, - EventNames = new List { ModuleConstants.EventNames.ViewItem }, - DimensionNames = new List { ModuleConstants.Dimensions.ItemId, ModuleConstants.Dimensions.ItemName }, - DimensionFilters = CreateUserDimensionFilters(), - Take = 1, - }; - } - - protected virtual IList CreateUserDimensionFilters() - { - var sessionKindFilter = AbstractTypeFactory.TryCreateInstance(); - sessionKindFilter.DimensionName = ModuleConstants.UserDimensions.SessionKind; - sessionKindFilter.Values = new List { ModuleConstants.SessionKinds.Self }; - - var organizationFilter = AbstractTypeFactory.TryCreateInstance(); - organizationFilter.DimensionName = ModuleConstants.UserDimensions.OrganizationId; - organizationFilter.Values = new List { ProbeFilterValue }; - - return new List { sessionKindFilter, organizationFilter }; - } - - protected virtual ISet GetRequestedFieldNames(RunReportRequest request) - { - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - result.UnionWith(request.Dimensions.Select(x => x.Name)); - result.UnionWith(request.Metrics.Select(x => x.Name)); - CollectFilterFieldNames(request.DimensionFilter, result); - return result; - } - - protected virtual void CollectFilterFieldNames(FilterExpression expression, ISet fieldNames) - { - if (expression == null) - { - return; - } - - if (!string.IsNullOrEmpty(expression.Filter?.FieldName)) - { - fieldNames.Add(expression.Filter.FieldName); - } - - if (expression.AndGroup != null) - { - foreach (var child in expression.AndGroup.Expressions) - { - CollectFilterFieldNames(child, fieldNames); - } - } - } -} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs new file mode 100644 index 0000000..412b95b --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -0,0 +1,623 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Google.Analytics.Data.V1Beta; +using Google.Api.Gax.Grpc; +using Grpc.Core; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.Platform.Core.Common; +using ErrorInfo = Google.Rpc.ErrorInfo; +using Metadata = Google.Analytics.Data.V1Beta.Metadata; +using Stages = VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.ModuleConstants.DiagnosticsStages; +using Statuses = VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.ModuleConstants.DiagnosticsStatuses; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +public class AnalyticsDiagnosticsService : IAnalyticsDiagnosticsService +{ + private const string UserDimensionPrefix = "customUser:"; + private const string EventCountMetric = "eventCount"; + private const string CredentialTypeProperty = "type"; + private const string ProbeFilterValue = "diagnostics-probe"; + private const string ProcessedDataStartDate = "7daysAgo"; + private const string ProcessedDataEndDate = "today"; + private const int RealtimeMinutesAgo = 29; + private const int LiveDataRowsLimit = 50; + + private const string ScopeInsufficientReason = "ACCESS_TOKEN_SCOPE_INSUFFICIENT"; + private const string ServiceDisabledReason = "SERVICE_DISABLED"; + private const string UserProjectDeniedReason = "USER_PROJECT_DENIED"; + private const string ConsumerMetadataKey = "consumer"; + private const string ProjectResourcePrefix = "projects/"; + + private static readonly string[] AllStages = + { + Stages.Configuration, + Stages.Credentials, + Stages.ApiAccess, + Stages.CustomDimensions, + Stages.ReportCompatibility, + Stages.Realtime, + Stages.ProcessedData, + }; + + private readonly IAnalyticsSettingsResolver _settingsResolver; + private readonly IGoogleAnalyticsReportClient _reportClient; + + public AnalyticsDiagnosticsService(IAnalyticsSettingsResolver settingsResolver, IGoogleAnalyticsReportClient reportClient) + { + _settingsResolver = settingsResolver; + _reportClient = reportClient; + } + + public virtual async Task RunAsync(string storeId, AnalyticsDiagnosticsRequest request) + { + request ??= AbstractTypeFactory.TryCreateInstance(); + var result = AbstractTypeFactory.TryCreateInstance(); + var checks = result.Checks; + + var settings = await CheckConfigurationAsync(storeId, checks); + if (settings == null) + { + SkipRemainingStages(checks, checks[0].Status == Statuses.Warning + ? "Skipped: sample data mode is active." + : "Skipped: configuration check failed."); + return result; + } + + if (!await CheckCredentialsAsync(settings, checks)) + { + SkipRemainingStages(checks, "Skipped: credential resolution failed."); + return result; + } + + var metadata = await CheckApiAccessAsync(settings, checks); + if (metadata == null) + { + SkipRemainingStages(checks, "Skipped: Google Analytics Data API is not accessible."); + return result; + } + + CheckCustomDimensions(metadata, request, checks); + await CheckReportCompatibilityAsync(settings, request, checks); + await CheckLiveDataAsync(settings, request, checks); + + return result; + } + + protected virtual async Task CheckConfigurationAsync(string storeId, IList checks) + { + AnalyticsDataApiSettings settings; + + try + { + settings = await _settingsResolver.ResolveAsync(storeId); + } + catch (Exception ex) + { + AddCheck(checks, Stages.Configuration, Statuses.Failed, + $"Failed to resolve Google Analytics settings for store '{storeId}'.", ex.Message); + return null; + } + + if (settings.SampleDataEnabled) + { + AddCheck(checks, Stages.Configuration, Statuses.Warning, + "Sample data mode is active — Google Analytics is not exercised."); + return null; + } + + if (!settings.IsGoogleConfigured) + { + AddCheck(checks, Stages.Configuration, Statuses.Failed, + "GA4 Data API property id is not configured for the store (GoogleAnalytics4.DataApi.PropertyId)."); + return null; + } + + AddCheck(checks, Stages.Configuration, Statuses.Passed, + $"Property {settings.PropertyId} configured; credential: {DescribeCredentialSource(settings.CredentialJson)}."); + return settings; + } + + protected virtual async Task CheckCredentialsAsync(AnalyticsDataApiSettings settings, IList checks) + { + try + { + await _reportClient.ValidateCredentialAsync(settings.CredentialJson); + AddCheck(checks, Stages.Credentials, Statuses.Passed, "Credential resolved successfully."); + return true; + } + catch (Exception ex) + { + var message = string.IsNullOrWhiteSpace(settings.CredentialJson) + ? "Application Default Credentials are not available — set GoogleAnalytics4.DataApi.ServiceAccountJson or provide ADC (GOOGLE_APPLICATION_CREDENTIALS)." + : "The configured credential JSON cannot be used — check GoogleAnalytics4.DataApi.ServiceAccountJson for malformed or incomplete JSON."; + AddCheck(checks, Stages.Credentials, Statuses.Failed, message, ex.Message); + return false; + } + } + + protected virtual async Task CheckApiAccessAsync(AnalyticsDataApiSettings settings, IList checks) + { + try + { + var metadata = await _reportClient.GetMetadataAsync(settings.CredentialJson, settings.PropertyId); + if (metadata == null) + { + AddCheck(checks, Stages.ApiAccess, Statuses.Failed, "Google Analytics Data API returned no metadata."); + return null; + } + + AddCheck(checks, Stages.ApiAccess, Statuses.Passed, + $"Google Analytics Data API is reachable for property {settings.PropertyId}."); + return metadata; + } + catch (RpcException ex) + { + AddCheck(checks, Stages.ApiAccess, Statuses.Failed, MapApiAccessFailure(ex, settings.PropertyId), DescribeError(ex)); + return null; + } + catch (Exception ex) + { + AddCheck(checks, Stages.ApiAccess, Statuses.Failed, "Google Analytics Data API call failed.", ex.Message); + return null; + } + } + + protected virtual string MapApiAccessFailure(RpcException exception, string propertyId) + { + var errorInfo = exception.GetStatusDetail(); + var reason = errorInfo?.Reason; + + if (reason == ScopeInsufficientReason) + { + return "The credential's access token lacks the required scope — re-authorize or re-issue it with the https://www.googleapis.com/auth/analytics.readonly scope."; + } + + if (reason == ServiceDisabledReason) + { + var project = GetProjectName(errorInfo); + var projectPart = string.IsNullOrEmpty(project) ? "the credential's project" : $"project {project}"; + return $"The Google Analytics Data API (analyticsdata.googleapis.com) is disabled in {projectPart} — enable it in the Google Cloud console and retry."; + } + + if (reason == UserProjectDeniedReason || exception.Status.Detail?.Contains("quota project", StringComparison.OrdinalIgnoreCase) == true) + { + return "The credential has no usable quota project — set a quota project for the credential or use a service account key from a project with the Data API enabled."; + } + + if (exception.StatusCode == StatusCode.PermissionDenied) + { + return $"The credential has no access to GA4 property {propertyId} — grant its principal the Viewer role in GA4 Admin > Property access management."; + } + + if (exception.StatusCode == StatusCode.NotFound) + { + return $"GA4 property {propertyId} was not found — check the GoogleAnalytics4.DataApi.PropertyId setting (use the numeric property id, not the measurement id)."; + } + + return "Google Analytics Data API call failed."; + } + + protected virtual void CheckCustomDimensions(Metadata metadata, AnalyticsDiagnosticsRequest request, IList checks) + { + var requestedNames = GetUserDimensionNames(request); + if (requestedNames.Count == 0) + { + AddCheck(checks, Stages.CustomDimensions, Statuses.Skipped, "No user dimensions requested."); + return; + } + + var registeredNames = metadata.Dimensions + .Select(x => x.ApiName) + .Where(x => !string.IsNullOrEmpty(x)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var missingNames = requestedNames.Where(x => !registeredNames.Contains(UserDimensionPrefix + x)).ToList(); + + if (missingNames.Count == 0) + { + AddCheck(checks, Stages.CustomDimensions, Statuses.Passed, + $"All requested user dimensions are registered: {string.Join(", ", requestedNames)}."); + } + else + { + AddCheck(checks, Stages.CustomDimensions, Statuses.Failed, + $"Missing user-scoped custom dimensions: {string.Join(", ", missingNames)}. " + + "Register them in GA4 Admin > Custom definitions; registration is not retroactive — data collection for reporting starts at registration."); + } + } + + protected virtual async Task CheckReportCompatibilityAsync(AnalyticsDataApiSettings settings, AnalyticsDiagnosticsRequest request, IList checks) + { + var shapes = (request.Reports ?? []).Where(x => x != null).ToList(); + if (shapes.Count == 0) + { + AddCheck(checks, Stages.ReportCompatibility, Statuses.Skipped, "No report shapes requested."); + return; + } + + var userDimensionNames = GetUserDimensionNames(request); + var problems = new List(); + + try + { + foreach (var shape in shapes) + { + var compatibilityRequest = BuildCompatibilityRequest(settings.PropertyId, shape, userDimensionNames); + var response = await _reportClient.CheckCompatibilityAsync(settings.CredentialJson, compatibilityRequest); + var incompatibleFields = GetIncompatibleFields(response, GetRequestedFieldNames(compatibilityRequest)); + + if (incompatibleFields.Count > 0) + { + problems.Add($"{shape.Name}: {string.Join(", ", incompatibleFields)}"); + } + } + } + catch (Exception ex) + { + AddCheck(checks, Stages.ReportCompatibility, Statuses.Failed, "Report compatibility check failed.", DescribeError(ex)); + return; + } + + if (problems.Count == 0) + { + AddCheck(checks, Stages.ReportCompatibility, Statuses.Passed, + $"Compatible report shapes: {string.Join(", ", shapes.Select(x => x.Name))}."); + } + else + { + AddCheck(checks, Stages.ReportCompatibility, Statuses.Failed, + $"Incompatible fields — {string.Join("; ", problems)}."); + } + } + + protected virtual async Task CheckLiveDataAsync(AnalyticsDataApiSettings settings, AnalyticsDiagnosticsRequest request, IList checks) + { + if (!request.IncludeLiveData) + { + const string skipMessage = "Skipped: live-data checks disabled by request."; + AddCheck(checks, Stages.Realtime, Statuses.Skipped, skipMessage); + AddCheck(checks, Stages.ProcessedData, Statuses.Skipped, skipMessage); + return; + } + + await CheckRealtimeAsync(settings, request, checks); + await CheckProcessedDataAsync(settings, request, checks); + } + + protected virtual async Task CheckRealtimeAsync(AnalyticsDataApiSettings settings, AnalyticsDiagnosticsRequest request, IList checks) + { + var userDimensionNames = GetUserDimensionNames(request); + + try + { + RunRealtimeReportResponse response; + var checkedCustomDimensions = userDimensionNames.Count > 0; + + try + { + response = await _reportClient.RunRealtimeReportAsync(settings.CredentialJson, + BuildRealtimeRequest(settings.PropertyId, userDimensionNames)); + } + catch (RpcException ex) when (checkedCustomDimensions && ex.StatusCode == StatusCode.InvalidArgument) + { + checkedCustomDimensions = false; + response = await _reportClient.RunRealtimeReportAsync(settings.CredentialJson, + BuildRealtimeRequest(settings.PropertyId, new List())); + } + + var fallbackNote = !checkedCustomDimensions && userDimensionNames.Count > 0 + ? " Realtime does not support the custom dimensions on this property — checked event stream only." + : string.Empty; + var eventCounts = AggregateEventCounts(response.DimensionHeaders, response.Rows); + + if (eventCounts.Count == 0) + { + AddCheck(checks, Stages.Realtime, Statuses.Warning, "No events in the last 30 minutes." + fallbackNote); + } + else + { + AddCheck(checks, Stages.Realtime, Statuses.Passed, + $"Events in the last 30 minutes: {FormatEventCounts(eventCounts)}.{fallbackNote}{DescribeMissingRequestedEvents(request, eventCounts)}"); + } + } + catch (Exception ex) + { + AddCheck(checks, Stages.Realtime, Statuses.Failed, "Realtime report failed.", DescribeError(ex)); + } + } + + protected virtual async Task CheckProcessedDataAsync(AnalyticsDataApiSettings settings, AnalyticsDiagnosticsRequest request, IList checks) + { + try + { + var response = await _reportClient.RunReportAsync(settings.CredentialJson, BuildProcessedDataRequest(settings.PropertyId)); + var eventCounts = AggregateEventCounts(response.DimensionHeaders, response.Rows); + + if (eventCounts.Count == 0) + { + AddCheck(checks, Stages.ProcessedData, Statuses.Warning, + "No processed data yet — GA4 processes events in up to 24–48 hours."); + } + else + { + AddCheck(checks, Stages.ProcessedData, Statuses.Passed, + $"Processed events over the last 7 days: {FormatEventCounts(eventCounts)}.{DescribeMissingRequestedEvents(request, eventCounts)}"); + } + } + catch (Exception ex) + { + AddCheck(checks, Stages.ProcessedData, Statuses.Failed, "Processed-data report failed.", DescribeError(ex)); + } + } + + protected virtual CheckCompatibilityRequest BuildCompatibilityRequest(string propertyId, AnalyticsDiagnosticsReportShape shape, IList userDimensionNames) + { + var request = new CheckCompatibilityRequest + { + Property = $"properties/{propertyId}", + CompatibilityFilter = Compatibility.Incompatible, + }; + + foreach (var dimensionName in (shape.DimensionNames ?? []).Where(x => !string.IsNullOrEmpty(x))) + { + request.Dimensions.Add(new Dimension { Name = MapDimensionName(dimensionName, userDimensionNames) }); + } + + if (!string.IsNullOrEmpty(shape.MetricName)) + { + request.Metrics.Add(new Metric { Name = shape.MetricName }); + } + + var expressions = new List(); + var eventNames = (shape.EventNames ?? []).Where(x => !string.IsNullOrEmpty(x)).ToList(); + if (eventNames.Count > 0) + { + expressions.Add(CreateInListExpression(ModuleConstants.Dimensions.EventName, eventNames)); + } + + expressions.AddRange(userDimensionNames + .Select(x => CreateInListExpression(UserDimensionPrefix + x, new[] { ProbeFilterValue }))); + + var dimensionFilter = CombineExpressions(expressions); + if (dimensionFilter != null) + { + request.DimensionFilter = dimensionFilter; + } + + return request; + } + + protected virtual RunRealtimeReportRequest BuildRealtimeRequest(string propertyId, IList userDimensionNames) + { + var request = new RunRealtimeReportRequest + { + Property = $"properties/{propertyId}", + Limit = LiveDataRowsLimit, + }; + + request.Dimensions.Add(new Dimension { Name = ModuleConstants.Dimensions.EventName }); + foreach (var dimensionName in userDimensionNames) + { + request.Dimensions.Add(new Dimension { Name = UserDimensionPrefix + dimensionName }); + } + + request.Metrics.Add(new Metric { Name = EventCountMetric }); + request.MinuteRanges.Add(new MinuteRange { StartMinutesAgo = RealtimeMinutesAgo, EndMinutesAgo = 0 }); + + return request; + } + + protected virtual RunReportRequest BuildProcessedDataRequest(string propertyId) + { + var request = new RunReportRequest + { + Property = $"properties/{propertyId}", + Limit = LiveDataRowsLimit, + }; + + request.DateRanges.Add(new DateRange { StartDate = ProcessedDataStartDate, EndDate = ProcessedDataEndDate }); + request.Dimensions.Add(new Dimension { Name = ModuleConstants.Dimensions.EventName }); + request.Metrics.Add(new Metric { Name = EventCountMetric }); + + return request; + } + + protected virtual IList GetIncompatibleFields(CheckCompatibilityResponse response, ISet requestedFieldNames) + { + var incompatibleDimensions = response.DimensionCompatibilities + .Where(x => x.Compatibility == Compatibility.Incompatible) + .Select(x => x.DimensionMetadata?.ApiName); + var incompatibleMetrics = response.MetricCompatibilities + .Where(x => x.Compatibility == Compatibility.Incompatible) + .Select(x => x.MetricMetadata?.ApiName); + + return incompatibleDimensions + .Concat(incompatibleMetrics) + .Where(x => !string.IsNullOrEmpty(x) && requestedFieldNames.Contains(x)) + .ToList(); + } + + protected virtual ISet GetRequestedFieldNames(CheckCompatibilityRequest request) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + result.UnionWith(request.Dimensions.Select(x => x.Name)); + result.UnionWith(request.Metrics.Select(x => x.Name)); + CollectFilterFieldNames(request.DimensionFilter, result); + return result; + } + + protected virtual void CollectFilterFieldNames(FilterExpression expression, ISet fieldNames) + { + if (expression == null) + { + return; + } + + if (!string.IsNullOrEmpty(expression.Filter?.FieldName)) + { + fieldNames.Add(expression.Filter.FieldName); + } + + if (expression.AndGroup != null) + { + foreach (var child in expression.AndGroup.Expressions) + { + CollectFilterFieldNames(child, fieldNames); + } + } + } + + protected virtual IList> AggregateEventCounts(IEnumerable dimensionHeaders, IEnumerable rows) + { + var eventNameIndex = dimensionHeaders + .Select((header, index) => (header.Name, Index: index)) + .Where(x => x.Name == ModuleConstants.Dimensions.EventName) + .Select(x => (int?)x.Index) + .FirstOrDefault(); + + var counts = new Dictionary(); + + foreach (var row in rows) + { + var eventName = eventNameIndex != null && row.DimensionValues.Count > eventNameIndex.Value + ? row.DimensionValues[eventNameIndex.Value].Value + : null; + if (string.IsNullOrEmpty(eventName)) + { + continue; + } + + var count = row.MetricValues.Count > 0 + && long.TryParse(row.MetricValues[0].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) + ? value + : 0; + counts[eventName] = counts.TryGetValue(eventName, out var existing) ? existing + count : count; + } + + return counts.OrderByDescending(x => x.Value).ToList(); + } + + protected virtual string FormatEventCounts(IList> eventCounts) + { + return string.Join(", ", eventCounts.Select(x => FormattableString.Invariant($"{x.Key}={x.Value}"))); + } + + protected virtual string DescribeMissingRequestedEvents(AnalyticsDiagnosticsRequest request, IList> eventCounts) + { + var missingNames = (request.EventNames ?? []) + .Where(x => !string.IsNullOrEmpty(x) && eventCounts.All(count => count.Key != x)) + .Distinct() + .ToList(); + + return missingNames.Count > 0 ? $" Requested events not seen: {string.Join(", ", missingNames)}." : string.Empty; + } + + protected virtual string DescribeCredentialSource(string credentialJson) + { + if (string.IsNullOrWhiteSpace(credentialJson)) + { + return "Application Default Credentials"; + } + + string credentialType = null; + try + { + credentialType = JObject.Parse(credentialJson)[CredentialTypeProperty]?.Value(); + } + catch (JsonException) + { + // Malformed JSON is diagnosed by the credentials stage; the source kind is still reported here. + } + + return string.IsNullOrEmpty(credentialType) ? "JSON from setting" : $"{credentialType} from setting"; + } + + protected virtual string DescribeError(Exception exception) + { + return exception is RpcException rpcException + ? $"{rpcException.StatusCode}: {rpcException.Status.Detail}" + : exception.Message; + } + + protected virtual IList GetUserDimensionNames(AnalyticsDiagnosticsRequest request) + { + return (request.UserDimensionNames ?? []) + .Where(x => !string.IsNullOrEmpty(x)) + .Distinct() + .ToList(); + } + + protected virtual string MapDimensionName(string dimensionName, IList userDimensionNames) + { + return userDimensionNames.Contains(dimensionName) || ModuleConstants.UserDimensions.AllNames.Contains(dimensionName) + ? UserDimensionPrefix + dimensionName + : dimensionName; + } + + protected virtual void SkipRemainingStages(IList checks, string message) + { + foreach (var stage in AllStages.Skip(checks.Count)) + { + AddCheck(checks, stage, Statuses.Skipped, message); + } + } + + protected virtual AnalyticsDiagnosticsCheck AddCheck(IList checks, string stage, string status, string message, string detail = null) + { + var check = AbstractTypeFactory.TryCreateInstance(); + check.Stage = stage; + check.Status = status; + check.Message = message; + check.Detail = detail; + checks.Add(check); + return check; + } + + private static string GetProjectName(ErrorInfo errorInfo) + { + if (errorInfo?.Metadata == null || !errorInfo.Metadata.TryGetValue(ConsumerMetadataKey, out var consumer)) + { + return null; + } + + return consumer.StartsWith(ProjectResourcePrefix, StringComparison.Ordinal) + ? consumer[ProjectResourcePrefix.Length..] + : consumer; + } + + private static FilterExpression CombineExpressions(IList expressions) + { + if (expressions.Count == 0) + { + return null; + } + + if (expressions.Count == 1) + { + return expressions[0]; + } + + var result = new FilterExpression { AndGroup = new FilterExpressionList() }; + result.AndGroup.Expressions.AddRange(expressions); + return result; + } + + private static FilterExpression CreateInListExpression(string fieldName, IEnumerable values) + { + var inListFilter = new Filter.Types.InListFilter(); + inListFilter.Values.AddRange(values); + + return new FilterExpression + { + Filter = new Filter { FieldName = fieldName, InListFilter = inListFilter }, + }; + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs index 4af89ba..116e4d6 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs @@ -11,12 +11,29 @@ public class GoogleAnalyticsReportClient : IGoogleAnalyticsReportClient private static readonly string[] AnalyticsReadOnlyScopes = { "https://www.googleapis.com/auth/analytics.readonly" }; + public virtual Task ValidateCredentialAsync(string credentialJson) + { + return ResolveCredentialAsync(credentialJson); + } + + public virtual async Task GetMetadataAsync(string credentialJson, string propertyId) + { + var client = await CreateClientAsync(credentialJson); + return await client.GetMetadataAsync(new GetMetadataRequest { Name = $"properties/{propertyId}/metadata" }); + } + public virtual async Task RunReportAsync(string credentialJson, RunReportRequest request) { var client = await CreateClientAsync(credentialJson); return await client.RunReportAsync(request); } + public virtual async Task RunRealtimeReportAsync(string credentialJson, RunRealtimeReportRequest request) + { + var client = await CreateClientAsync(credentialJson); + return await client.RunRealtimeReportAsync(request); + } + public virtual async Task CheckCompatibilityAsync(string credentialJson, CheckCompatibilityRequest request) { var client = await CreateClientAsync(credentialJson); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsCompatibilityService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsCompatibilityService.cs deleted file mode 100644 index b3474db..0000000 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsCompatibilityService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Threading.Tasks; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; - -namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; - -public interface IAnalyticsCompatibilityService -{ - Task CheckCompatibilityAsync(string storeId); -} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs index d95cebf..c2ff89b 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs @@ -5,7 +5,13 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; public interface IGoogleAnalyticsReportClient { + Task ValidateCredentialAsync(string credentialJson); + + Task GetMetadataAsync(string credentialJson, string propertyId); + Task RunReportAsync(string credentialJson, RunReportRequest request); + Task RunRealtimeReportAsync(string credentialJson, RunRealtimeReportRequest request); + Task CheckCompatibilityAsync(string credentialJson, CheckCompatibilityRequest request); } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Controllers/Api/GoogleAnalyticsController.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Controllers/Api/GoogleAnalyticsController.cs index 3bdd359..3f70feb 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Controllers/Api/GoogleAnalyticsController.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Controllers/Api/GoogleAnalyticsController.cs @@ -4,8 +4,6 @@ using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; using VirtoCommerce.Platform.Core.Settings; using GoogleSettings = VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.ModuleConstants.Settings.General; @@ -16,16 +14,16 @@ public class GoogleAnalyticsController : Controller { private readonly IGoogleAnalyticsSettingsManager _settings; private readonly ISettingsManager _settingsManager; - private readonly IAnalyticsCompatibilityService _compatibilityService; + private readonly IAnalyticsDiagnosticsService _diagnosticsService; public GoogleAnalyticsController( IGoogleAnalyticsSettingsManager settings, ISettingsManager settingsManager, - IAnalyticsCompatibilityService compatibilityService) + IAnalyticsDiagnosticsService diagnosticsService) { _settings = settings; _settingsManager = settingsManager; - _compatibilityService = compatibilityService; + _diagnosticsService = diagnosticsService; } [HttpGet] @@ -35,12 +33,12 @@ public Task GetStoreSettings(string storeId) return _settings.GetAsync(storeId); } - [HttpGet] - [Route("{storeId}/compatibility")] + [HttpPost] + [Route("{storeId}/diagnostics")] [Authorize(ModuleConstants.Security.Permissions.Access)] - public async Task> CheckCompatibility(string storeId) + public async Task> RunDiagnostics(string storeId, [FromBody] AnalyticsDiagnosticsRequest request) { - return Ok(await _compatibilityService.CheckCompatibilityAsync(storeId)); + return Ok(await _diagnosticsService.RunAsync(storeId, request)); } [HttpGet] diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs index b1f4476..63bd669 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs @@ -25,8 +25,12 @@ public void Initialize(IServiceCollection serviceCollection) serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); - serviceCollection.AddTransient(); + serviceCollection.AddTransient(); + AbstractTypeFactory.RegisterType(); + AbstractTypeFactory.RegisterType(); + AbstractTypeFactory.RegisterType(); + AbstractTypeFactory.RegisterType(); AbstractTypeFactory.RegisterType(); AbstractTypeFactory.RegisterType(); AbstractTypeFactory.RegisterType(); diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsCompatibilityServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsCompatibilityServiceTests.cs deleted file mode 100644 index 516d702..0000000 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsCompatibilityServiceTests.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Google.Analytics.Data.V1Beta; -using Moq; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; -using Xunit; - -namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; - -public class AnalyticsCompatibilityServiceTests -{ - private const string StoreId = "test-store"; - - private readonly Mock _settingsResolverMock = new(); - private readonly Mock _reportClientMock = new(); - private readonly List _capturedRequests = new(); - - [Fact] - public async Task CheckCompatibilityAsync_SampleDataEnabled_ReportsUnavailableWithoutCallingGoogle() - { - SetupSettings(new AnalyticsDataApiSettings { PropertyId = "123456", SampleDataEnabled = true }); - var service = CreateService(); - - var result = await service.CheckCompatibilityAsync(StoreId); - - Assert.False(result.Available); - Assert.Contains("Sample data", result.ErrorMessage); - Assert.Empty(result.Reports); - _reportClientMock.Verify(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task CheckCompatibilityAsync_NoPropertyId_ReportsErrorWithoutCallingGoogle() - { - SetupSettings(new AnalyticsDataApiSettings()); - var service = CreateService(); - - var result = await service.CheckCompatibilityAsync(StoreId); - - Assert.False(result.Available); - Assert.Contains("not configured", result.ErrorMessage); - Assert.Empty(result.Reports); - _reportClientMock.Verify(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task CheckCompatibilityAsync_ResolverThrows_ReturnsErrorPayload() - { - _settingsResolverMock - .Setup(x => x.ResolveAsync(It.IsAny())) - .ThrowsAsync(new InvalidOperationException("boom")); - var service = CreateService(); - - var result = await service.CheckCompatibilityAsync(StoreId); - - Assert.False(result.Available); - Assert.Equal("boom", result.ErrorMessage); - } - - [Fact] - public async Task CheckCompatibilityAsync_BuildsCanonicalReportRequests() - { - SetupSettings(CreateGoogleSettings()); - SetupClient(new CheckCompatibilityResponse()); - var service = CreateService(); - - var result = await service.CheckCompatibilityAsync(StoreId); - - Assert.True(result.Available); - Assert.Null(result.ErrorMessage); - Assert.Equal(new[] { "searchTerms", "browsedProducts" }, result.Reports.Select(x => x.Report)); - Assert.All(result.Reports, x => Assert.True(x.Compatible)); - Assert.All(result.Reports, x => Assert.Empty(x.IncompatibleDimensions)); - Assert.All(result.Reports, x => Assert.Empty(x.IncompatibleMetrics)); - - Assert.Equal(2, _capturedRequests.Count); - - var searchTermsRequest = _capturedRequests[0]; - Assert.Equal("properties/123456", searchTermsRequest.Property); - Assert.Equal(Compatibility.Incompatible, searchTermsRequest.CompatibilityFilter); - Assert.Equal(new[] { "eventName", "dateHour", "searchTerm" }, searchTermsRequest.Dimensions.Select(x => x.Name)); - Assert.Equal("eventCount", Assert.Single(searchTermsRequest.Metrics).Name); - Assert.Equal( - new[] { "eventName", "customUser:session_kind", "customUser:organization_id" }, - searchTermsRequest.DimensionFilter.AndGroup.Expressions.Select(x => x.Filter.FieldName)); - Assert.Equal( - new[] { "search", "view_search_results" }, - searchTermsRequest.DimensionFilter.AndGroup.Expressions[0].Filter.InListFilter.Values); - - var browsedProductsRequest = _capturedRequests[1]; - Assert.Equal("properties/123456", browsedProductsRequest.Property); - Assert.Equal(new[] { "dateHour", "itemId", "itemName" }, browsedProductsRequest.Dimensions.Select(x => x.Name)); - Assert.Equal("itemsViewed", Assert.Single(browsedProductsRequest.Metrics).Name); - Assert.Equal( - new[] { "view_item" }, - browsedProductsRequest.DimensionFilter.AndGroup.Expressions[0].Filter.InListFilter.Values); - } - - [Fact] - public async Task CheckCompatibilityAsync_MapsIncompatibleFieldsIntersectedWithRequested() - { - SetupSettings(CreateGoogleSettings()); - - var response = new CheckCompatibilityResponse(); - response.DimensionCompatibilities.Add(new DimensionCompatibility - { - Compatibility = Compatibility.Incompatible, - DimensionMetadata = new DimensionMetadata { ApiName = "customUser:organization_id" }, - }); - response.DimensionCompatibilities.Add(new DimensionCompatibility - { - Compatibility = Compatibility.Incompatible, - DimensionMetadata = new DimensionMetadata { ApiName = "unrelatedDimension" }, - }); - response.MetricCompatibilities.Add(new MetricCompatibility - { - Compatibility = Compatibility.Incompatible, - MetricMetadata = new MetricMetadata { ApiName = "itemsViewed" }, - }); - SetupClient(response); - var service = CreateService(); - - var result = await service.CheckCompatibilityAsync(StoreId); - - var searchTerms = result.Reports.First(x => x.Report == "searchTerms"); - Assert.False(searchTerms.Compatible); - Assert.Equal(new[] { "customUser:organization_id" }, searchTerms.IncompatibleDimensions); - Assert.Empty(searchTerms.IncompatibleMetrics); - - var browsedProducts = result.Reports.First(x => x.Report == "browsedProducts"); - Assert.False(browsedProducts.Compatible); - Assert.Equal(new[] { "customUser:organization_id" }, browsedProducts.IncompatibleDimensions); - Assert.Equal(new[] { "itemsViewed" }, browsedProducts.IncompatibleMetrics); - } - - [Fact] - public async Task CheckCompatibilityAsync_ClientThrows_SetsReportErrorMessageInsteadOfFailing() - { - SetupSettings(CreateGoogleSettings()); - _reportClientMock - .Setup(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new InvalidOperationException("credentials missing")); - var service = CreateService(); - - var result = await service.CheckCompatibilityAsync(StoreId); - - Assert.True(result.Available); - Assert.Equal(2, result.Reports.Count); - Assert.All(result.Reports, x => - { - Assert.False(x.Compatible); - Assert.Equal("credentials missing", x.ErrorMessage); - }); - } - - private AnalyticsCompatibilityService CreateService() - { - return new AnalyticsCompatibilityService( - _settingsResolverMock.Object, - new GoogleAnalyticsDataSource(_reportClientMock.Object), - _reportClientMock.Object); - } - - private void SetupSettings(AnalyticsDataApiSettings settings) - { - _settingsResolverMock - .Setup(x => x.ResolveAsync(StoreId)) - .ReturnsAsync(settings); - } - - private void SetupClient(CheckCompatibilityResponse response) - { - _reportClientMock - .Setup(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny())) - .Callback((string _, CheckCompatibilityRequest request) => _capturedRequests.Add(request)) - .ReturnsAsync(response); - } - - private static AnalyticsDataApiSettings CreateGoogleSettings() - { - return new AnalyticsDataApiSettings { PropertyId = "123456", CredentialJson = "{}" }; - } -} diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs new file mode 100644 index 0000000..94ac07d --- /dev/null +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs @@ -0,0 +1,649 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Google.Analytics.Data.V1Beta; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Moq; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; +using VirtoCommerce.Platform.Core.Caching; +using Xunit; +using ErrorInfo = Google.Rpc.ErrorInfo; +using GaMetadata = Google.Analytics.Data.V1Beta.Metadata; +using GrpcMetadata = Grpc.Core.Metadata; +using GrpcStatus = Grpc.Core.Status; +using RpcStatus = Google.Rpc.Status; +using Stages = VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.ModuleConstants.DiagnosticsStages; +using Statuses = VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.ModuleConstants.DiagnosticsStatuses; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; + +public class AnalyticsDiagnosticsServiceTests +{ + private const string StoreId = "test-store"; + private const string PropertyId = "123456"; + + private static readonly string[] StageOrder = + { + Stages.Configuration, + Stages.Credentials, + Stages.ApiAccess, + Stages.CustomDimensions, + Stages.ReportCompatibility, + Stages.Realtime, + Stages.ProcessedData, + }; + + private readonly Mock _settingsResolverMock = new(); + private readonly Mock _reportClientMock = new(); + private readonly List _capturedCompatibilityRequests = new(); + private readonly List _capturedRealtimeRequests = new(); + private readonly List _capturedReportRequests = new(); + + [Fact] + public async Task RunAsync_HappyPath_AllStagesPassInStableOrder() + { + SetupHappyGooglePath(); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + AssertStageOrder(result); + Assert.All(result.Checks, x => Assert.Equal(Statuses.Passed, x.Status)); + + var configuration = GetCheck(result, Stages.Configuration); + Assert.Contains(PropertyId, configuration.Message); + Assert.Contains("service_account from setting", configuration.Message); + Assert.DoesNotContain("private", configuration.Message); + + Assert.Contains("session_kind", GetCheck(result, Stages.CustomDimensions).Message); + Assert.Contains("searchTerms", GetCheck(result, Stages.ReportCompatibility).Message); + + var realtime = GetCheck(result, Stages.Realtime); + Assert.Contains("search=5", realtime.Message); + Assert.Contains("Requested events not seen: view_item", realtime.Message); + Assert.DoesNotContain("checked event stream only", realtime.Message); + + var processedData = GetCheck(result, Stages.ProcessedData); + Assert.Contains("search=10", processedData.Message); + Assert.Contains("view_item=4", processedData.Message); + Assert.DoesNotContain("Requested events not seen", processedData.Message); + } + + [Fact] + public async Task RunAsync_NullRequest_RunsWithDefaults() + { + SetupHappyGooglePath(); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, null); + + AssertStageOrder(result); + Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.CustomDimensions).Status); + Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.ReportCompatibility).Status); + Assert.Equal(Statuses.Passed, GetCheck(result, Stages.Realtime).Status); + Assert.Equal(Statuses.Passed, GetCheck(result, Stages.ProcessedData).Status); + Assert.Single(Assert.Single(_capturedRealtimeRequests).Dimensions); + } + + [Fact] + public async Task RunAsync_SampleMode_WarnsAndSkipsGoogleStages() + { + SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId, SampleDataEnabled = true }); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + AssertStageOrder(result); + var configuration = GetCheck(result, Stages.Configuration); + Assert.Equal(Statuses.Warning, configuration.Status); + Assert.Contains("Sample data mode", configuration.Message); + Assert.All(result.Checks.Skip(1), x => + { + Assert.Equal(Statuses.Skipped, x.Status); + Assert.Contains("sample data mode", x.Message); + }); + _reportClientMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RunAsync_NoPropertyId_FailsConfigurationAndSkipsRest() + { + SetupSettings(new AnalyticsDataApiSettings()); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + AssertStageOrder(result); + var configuration = GetCheck(result, Stages.Configuration); + Assert.Equal(Statuses.Failed, configuration.Status); + Assert.Contains("PropertyId", configuration.Message); + Assert.All(result.Checks.Skip(1), x => Assert.Equal(Statuses.Skipped, x.Status)); + _reportClientMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task RunAsync_ResolverThrows_FailsConfigurationWithDetail() + { + _settingsResolverMock + .Setup(x => x.ResolveAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + AssertStageOrder(result); + var configuration = GetCheck(result, Stages.Configuration); + Assert.Equal(Statuses.Failed, configuration.Status); + Assert.Equal("boom", configuration.Detail); + Assert.All(result.Checks.Skip(1), x => Assert.Equal(Statuses.Skipped, x.Status)); + } + + [Fact] + public async Task RunAsync_AdcCredential_ReportsAdcSource() + { + SetupHappyGooglePath(); + SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId }); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + Assert.Contains("Application Default Credentials", GetCheck(result, Stages.Configuration).Message); + } + + [Fact] + public async Task RunAsync_UnparseableCredentialJson_ReportsSourceWithoutContent() + { + SetupHappyGooglePath(); + SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId, CredentialJson = "{secret-not-json" }); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + var configuration = GetCheck(result, Stages.Configuration); + Assert.Contains("JSON from setting", configuration.Message); + Assert.DoesNotContain("secret", configuration.Message); + } + + [Fact] + public async Task Credentials_AdcNotFound_FailsAndSkipsRest() + { + SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId }); + _reportClientMock + .Setup(x => x.ValidateCredentialAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("The Application Default Credentials are not available.")); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + AssertStageOrder(result); + var credentials = GetCheck(result, Stages.Credentials); + Assert.Equal(Statuses.Failed, credentials.Status); + Assert.Contains("Application Default Credentials", credentials.Message); + Assert.All(result.Checks.Skip(2), x => Assert.Equal(Statuses.Skipped, x.Status)); + _reportClientMock.Verify(x => x.GetMetadataAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Credentials_MalformedConfiguredJson_MessagePointsAtSetting() + { + SetupGoogleSettings(); + _reportClientMock + .Setup(x => x.ValidateCredentialAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Error creating credential from JSON.")); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + var credentials = GetCheck(result, Stages.Credentials); + Assert.Equal(Statuses.Failed, credentials.Status); + Assert.Contains("GoogleAnalytics4.DataApi.ServiceAccountJson", credentials.Message); + Assert.Equal("Error creating credential from JSON.", credentials.Detail); + } + + [Fact] + public async Task ApiAccess_ScopeInsufficient_MapsToReadonlyScopeMessage() + { + var apiAccess = await RunApiAccessFailureAsync(CreateRpcException( + StatusCode.PermissionDenied, "Request had insufficient authentication scopes.", "ACCESS_TOKEN_SCOPE_INSUFFICIENT")); + + Assert.Contains("https://www.googleapis.com/auth/analytics.readonly", apiAccess.Message); + } + + [Fact] + public async Task ApiAccess_ServiceDisabled_MapsToEnableApiMessageWithProject() + { + var apiAccess = await RunApiAccessFailureAsync(CreateRpcException( + StatusCode.PermissionDenied, + "Google Analytics Data API has not been used in project 98765 before or it is disabled.", + "SERVICE_DISABLED", + new Dictionary { ["consumer"] = "projects/98765" })); + + Assert.Contains("analyticsdata.googleapis.com", apiAccess.Message); + Assert.Contains("project 98765", apiAccess.Message); + } + + [Fact] + public async Task ApiAccess_UserProjectDenied_MapsToQuotaProjectMessage() + { + var apiAccess = await RunApiAccessFailureAsync(CreateRpcException( + StatusCode.PermissionDenied, "Permission denied on the consumer project.", "USER_PROJECT_DENIED")); + + Assert.Contains("quota project", apiAccess.Message); + } + + [Fact] + public async Task ApiAccess_QuotaProjectInMessage_MapsToQuotaProjectMessage() + { + var apiAccess = await RunApiAccessFailureAsync(CreateRpcException( + StatusCode.PermissionDenied, + "Your application is authenticating by using local Application Default Credentials. The analyticsdata.googleapis.com API requires a quota project, which is not set by default.")); + + Assert.Contains("quota project", apiAccess.Message); + } + + [Fact] + public async Task ApiAccess_PermissionDenied_MapsToGrantViewerMessage() + { + var apiAccess = await RunApiAccessFailureAsync(CreateRpcException( + StatusCode.PermissionDenied, "User does not have sufficient permissions for this property.")); + + Assert.Contains("Viewer", apiAccess.Message); + Assert.Contains(PropertyId, apiAccess.Message); + } + + [Fact] + public async Task ApiAccess_NotFound_MapsToWrongPropertyIdMessage() + { + var apiAccess = await RunApiAccessFailureAsync(CreateRpcException( + StatusCode.NotFound, "Could not find entity.")); + + Assert.Contains("was not found", apiAccess.Message); + Assert.Contains(PropertyId, apiAccess.Message); + } + + [Fact] + public async Task ApiAccess_UnmappedError_FailsWithRawDetail() + { + var apiAccess = await RunApiAccessFailureAsync(CreateRpcException(StatusCode.Internal, "backend error")); + + Assert.Equal("Google Analytics Data API call failed.", apiAccess.Message); + Assert.Contains("Internal", apiAccess.Detail); + Assert.Contains("backend error", apiAccess.Detail); + } + + [Fact] + public async Task CustomDimensions_Missing_FailsNamingThemAndLaterStagesStillRun() + { + SetupHappyGooglePath(); + SetupMetadata("customUser:session_kind"); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + var customDimensions = GetCheck(result, Stages.CustomDimensions); + Assert.Equal(Statuses.Failed, customDimensions.Status); + Assert.Contains("organization_id", customDimensions.Message); + Assert.DoesNotContain("session_kind,", customDimensions.Message); + Assert.Contains("Custom definitions", customDimensions.Message); + Assert.Contains("not retroactive", customDimensions.Message); + Assert.Equal(Statuses.Passed, GetCheck(result, Stages.ReportCompatibility).Status); + Assert.Equal(Statuses.Passed, GetCheck(result, Stages.Realtime).Status); + } + + [Fact] + public async Task CustomDimensions_NoneRequested_SkippedAndRealtimeOmitsCustomDimensions() + { + SetupHappyGooglePath(); + var request = CreateRequest(); + request.UserDimensionNames.Clear(); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, request); + + Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.CustomDimensions).Status); + Assert.Single(Assert.Single(_capturedRealtimeRequests).Dimensions); + } + + [Fact] + public async Task ReportCompatibility_BuildsRequestFromShapeAndUserDimensions() + { + SetupHappyGooglePath(); + var service = CreateService(); + + await service.RunAsync(StoreId, CreateRequest()); + + var request = Assert.Single(_capturedCompatibilityRequests); + Assert.Equal($"properties/{PropertyId}", request.Property); + Assert.Equal(Compatibility.Incompatible, request.CompatibilityFilter); + Assert.Equal(new[] { "eventName", "dateHour", "searchTerm" }, request.Dimensions.Select(x => x.Name)); + Assert.Equal("eventCount", Assert.Single(request.Metrics).Name); + Assert.Equal( + new[] { "eventName", "customUser:session_kind", "customUser:organization_id" }, + request.DimensionFilter.AndGroup.Expressions.Select(x => x.Filter.FieldName)); + Assert.Equal( + new[] { "search", "view_search_results" }, + request.DimensionFilter.AndGroup.Expressions[0].Filter.InListFilter.Values); + } + + [Fact] + public async Task ReportCompatibility_IncompatibleFieldsIntersectedWithRequested() + { + SetupHappyGooglePath(); + var response = new CheckCompatibilityResponse(); + response.DimensionCompatibilities.Add(new DimensionCompatibility + { + Compatibility = Compatibility.Incompatible, + DimensionMetadata = new DimensionMetadata { ApiName = "customUser:organization_id" }, + }); + response.DimensionCompatibilities.Add(new DimensionCompatibility + { + Compatibility = Compatibility.Incompatible, + DimensionMetadata = new DimensionMetadata { ApiName = "unrelatedDimension" }, + }); + response.MetricCompatibilities.Add(new MetricCompatibility + { + Compatibility = Compatibility.Incompatible, + MetricMetadata = new MetricMetadata { ApiName = "itemsViewed" }, + }); + SetupCompatibility(response); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + var reportCompatibility = GetCheck(result, Stages.ReportCompatibility); + Assert.Equal(Statuses.Failed, reportCompatibility.Status); + Assert.Contains("searchTerms", reportCompatibility.Message); + Assert.Contains("customUser:organization_id", reportCompatibility.Message); + Assert.DoesNotContain("unrelatedDimension", reportCompatibility.Message); + Assert.DoesNotContain("itemsViewed", reportCompatibility.Message); + } + + [Fact] + public async Task ReportCompatibility_ClientThrows_FailsStageButLiveDataStillRuns() + { + SetupHappyGooglePath(); + _reportClientMock + .Setup(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("credentials missing")); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + var reportCompatibility = GetCheck(result, Stages.ReportCompatibility); + Assert.Equal(Statuses.Failed, reportCompatibility.Status); + Assert.Equal("credentials missing", reportCompatibility.Detail); + Assert.Equal(Statuses.Passed, GetCheck(result, Stages.Realtime).Status); + Assert.Equal(Statuses.Passed, GetCheck(result, Stages.ProcessedData).Status); + } + + [Fact] + public async Task ReportCompatibility_NoShapes_Skipped() + { + SetupHappyGooglePath(); + var request = CreateRequest(); + request.Reports.Clear(); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, request); + + Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.ReportCompatibility).Status); + _reportClientMock.Verify(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Realtime_CustomDimensionsRejected_RetriesEventNameOnly() + { + SetupHappyGooglePath(); + _reportClientMock + .Setup(x => x.RunRealtimeReportAsync(It.IsAny(), It.Is(r => r.Dimensions.Count > 1))) + .Callback((string _, RunRealtimeReportRequest request) => _capturedRealtimeRequests.Add(request)) + .ThrowsAsync(CreateRpcException(StatusCode.InvalidArgument, "Field customUser:session_kind is not a valid dimension.")); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + var realtime = GetCheck(result, Stages.Realtime); + Assert.Equal(Statuses.Passed, realtime.Status); + Assert.Contains("checked event stream only", realtime.Message); + Assert.Contains("search=5", realtime.Message); + Assert.Equal(2, _capturedRealtimeRequests.Count); + Assert.Equal(new[] { "eventName", "customUser:session_kind", "customUser:organization_id" }, _capturedRealtimeRequests[0].Dimensions.Select(x => x.Name)); + Assert.Equal(new[] { "eventName" }, _capturedRealtimeRequests[1].Dimensions.Select(x => x.Name)); + } + + [Fact] + public async Task LiveData_ZeroRows_WarningNotFailed() + { + SetupHappyGooglePath(); + SetupRealtime(new RunRealtimeReportResponse()); + SetupReport(new RunReportResponse()); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + var realtime = GetCheck(result, Stages.Realtime); + Assert.Equal(Statuses.Warning, realtime.Status); + Assert.Contains("No events in the last 30 minutes", realtime.Message); + + var processedData = GetCheck(result, Stages.ProcessedData); + Assert.Equal(Statuses.Warning, processedData.Status); + Assert.Contains("24–48", processedData.Message); + } + + [Fact] + public async Task LiveData_Disabled_SkipsBothChecks() + { + SetupHappyGooglePath(); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest(includeLiveData: false)); + + AssertStageOrder(result); + Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.Realtime).Status); + Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.ProcessedData).Status); + _reportClientMock.Verify(x => x.RunRealtimeReportAsync(It.IsAny(), It.IsAny()), Times.Never); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Realtime_HardFailure_FailsRealtimeOnlyProcessedStillRuns() + { + SetupHappyGooglePath(); + _reportClientMock + .Setup(x => x.RunRealtimeReportAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(CreateRpcException(StatusCode.Unavailable, "try again later")); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + var realtime = GetCheck(result, Stages.Realtime); + Assert.Equal(Statuses.Failed, realtime.Status); + Assert.Contains("Unavailable", realtime.Detail); + Assert.Equal(Statuses.Passed, GetCheck(result, Stages.ProcessedData).Status); + } + + [Fact] + public void Constructor_BypassesAnalyticsCacheAndService() + { + var parameterTypes = typeof(AnalyticsDiagnosticsService) + .GetConstructors() + .SelectMany(x => x.GetParameters()) + .Select(x => x.ParameterType) + .ToList(); + + Assert.DoesNotContain(typeof(IPlatformMemoryCache), parameterTypes); + Assert.DoesNotContain(typeof(IAnalyticsService), parameterTypes); + Assert.DoesNotContain(typeof(AnalyticsService), parameterTypes); + } + + private async Task RunApiAccessFailureAsync(RpcException exception) + { + SetupGoogleSettings(); + _reportClientMock + .Setup(x => x.GetMetadataAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(exception); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + AssertStageOrder(result); + var apiAccess = GetCheck(result, Stages.ApiAccess); + Assert.Equal(Statuses.Failed, apiAccess.Status); + Assert.All(result.Checks.Skip(3), x => Assert.Equal(Statuses.Skipped, x.Status)); + return apiAccess; + } + + private AnalyticsDiagnosticsService CreateService() + { + return new AnalyticsDiagnosticsService(_settingsResolverMock.Object, _reportClientMock.Object); + } + + private void SetupHappyGooglePath() + { + SetupGoogleSettings(); + SetupMetadata("customUser:session_kind", "customUser:organization_id"); + SetupCompatibility(new CheckCompatibilityResponse()); + SetupRealtime(CreateRealtimeResponse(("search", "2"), ("search", "3"))); + SetupReport(CreateReportResponse(("search", "10"), ("view_item", "4"))); + } + + private void SetupGoogleSettings() + { + SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId, CredentialJson = """{"type":"service_account"}""" }); + } + + private void SetupSettings(AnalyticsDataApiSettings settings) + { + _settingsResolverMock + .Setup(x => x.ResolveAsync(StoreId)) + .ReturnsAsync(settings); + } + + private void SetupMetadata(params string[] customDimensionApiNames) + { + var metadata = new GaMetadata(); + foreach (var apiName in customDimensionApiNames) + { + metadata.Dimensions.Add(new DimensionMetadata { ApiName = apiName, CustomDefinition = true }); + } + + _reportClientMock + .Setup(x => x.GetMetadataAsync(It.IsAny(), PropertyId)) + .ReturnsAsync(metadata); + } + + private void SetupCompatibility(CheckCompatibilityResponse response) + { + _reportClientMock + .Setup(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny())) + .Callback((string _, CheckCompatibilityRequest request) => _capturedCompatibilityRequests.Add(request)) + .ReturnsAsync(response); + } + + private void SetupRealtime(RunRealtimeReportResponse response) + { + _reportClientMock + .Setup(x => x.RunRealtimeReportAsync(It.IsAny(), It.IsAny())) + .Callback((string _, RunRealtimeReportRequest request) => _capturedRealtimeRequests.Add(request)) + .ReturnsAsync(response); + } + + private void SetupReport(RunReportResponse response) + { + _reportClientMock + .Setup(x => x.RunReportAsync(It.IsAny(), It.IsAny())) + .Callback((string _, RunReportRequest request) => _capturedReportRequests.Add(request)) + .ReturnsAsync(response); + } + + private static AnalyticsDiagnosticsRequest CreateRequest(bool includeLiveData = true) + { + return new AnalyticsDiagnosticsRequest + { + UserDimensionNames = new List { "session_kind", "organization_id" }, + EventNames = new List { "search", "view_item" }, + Reports = new List + { + new() + { + Name = "searchTerms", + DimensionNames = new List { "eventName", "dateHour", "searchTerm" }, + MetricName = "eventCount", + EventNames = new List { "search", "view_search_results" }, + }, + }, + IncludeLiveData = includeLiveData, + }; + } + + private static RunRealtimeReportResponse CreateRealtimeResponse(params (string EventName, string Count)[] rows) + { + var response = new RunRealtimeReportResponse(); + response.DimensionHeaders.Add(new DimensionHeader { Name = "eventName" }); + foreach (var (eventName, count) in rows) + { + response.Rows.Add(CreateRow(eventName, count)); + } + + return response; + } + + private static RunReportResponse CreateReportResponse(params (string EventName, string Count)[] rows) + { + var response = new RunReportResponse(); + response.DimensionHeaders.Add(new DimensionHeader { Name = "eventName" }); + foreach (var (eventName, count) in rows) + { + response.Rows.Add(CreateRow(eventName, count)); + } + + return response; + } + + private static Row CreateRow(string eventName, string count) + { + var row = new Row(); + row.DimensionValues.Add(new DimensionValue { Value = eventName }); + row.MetricValues.Add(new MetricValue { Value = count }); + return row; + } + + private static RpcException CreateRpcException(StatusCode statusCode, string message, string reason = null, IDictionary errorMetadata = null) + { + var status = new GrpcStatus(statusCode, message); + if (reason == null) + { + return new RpcException(status); + } + + var errorInfo = new ErrorInfo { Reason = reason, Domain = "googleapis.com" }; + if (errorMetadata != null) + { + foreach (var (key, value) in errorMetadata) + { + errorInfo.Metadata.Add(key, value); + } + } + + var rpcStatus = new RpcStatus { Code = (int)statusCode, Message = message, Details = { Any.Pack(errorInfo) } }; + var trailers = new GrpcMetadata { { "grpc-status-details-bin", rpcStatus.ToByteArray() } }; + return new RpcException(status, trailers); + } + + private static AnalyticsDiagnosticsCheck GetCheck(AnalyticsDiagnosticsResult result, string stage) + { + return result.Checks.Single(x => x.Stage == stage); + } + + private static void AssertStageOrder(AnalyticsDiagnosticsResult result) + { + Assert.Equal(StageOrder, result.Checks.Select(x => x.Stage)); + } +} From 954f85048e3cea7961ff177063a3b46b4d5dbbb9 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 27 Aug 2026 10:22:25 -0500 Subject: [PATCH 04/36] Refactoring (SonarQube issues) --- .../ModuleConstants.cs | 10 +-- .../Services/AnalyticsDiagnosticsService.cs | 2 +- .../Services/SampleAnalyticsDataSource.cs | 76 ++++++++++++------- .../Module.cs | 2 +- .../AnalyticsDiagnosticsServiceTests.cs | 15 ++-- .../GoogleAnalyticsDataSourceTests.cs | 22 ++++-- 6 files changed, 78 insertions(+), 49 deletions(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index f8510c0..4bd097d 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using VirtoCommerce.Platform.Core.Settings; namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core @@ -131,6 +130,8 @@ public static IEnumerable AllSettings public static class DataApi { + public const int DefaultCacheTtlMinutes = 60; + public static SettingDescriptor PropertyId { get; } = new SettingDescriptor { Name = "GoogleAnalytics4.DataApi.PropertyId", @@ -150,7 +151,7 @@ public static class DataApi Name = "GoogleAnalytics4.DataApi.CacheTtlMinutes", GroupName = "Google Analytics 4", ValueType = SettingValueType.PositiveInteger, - DefaultValue = 60 + DefaultValue = DefaultCacheTtlMinutes }; public static SettingDescriptor SampleDataEnabled { get; } = new SettingDescriptor @@ -173,11 +174,6 @@ public static IEnumerable AllSettings } } - public static IEnumerable AllSettings - { - get { return General.AllSettings.Concat(DataApi.AllSettings); } - } - public static IEnumerable StoreLevelSettings { get diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs index 412b95b..9a400c0 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -593,7 +593,7 @@ private static string GetProjectName(ErrorInfo errorInfo) : consumer; } - private static FilterExpression CombineExpressions(IList expressions) + private static FilterExpression CombineExpressions(List expressions) { if (expressions.Count == 0) { diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs index 6f2091f..1145b7a 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs @@ -14,6 +14,11 @@ public class SampleAnalyticsDataSource : IAnalyticsDataSource { private const int MaxHourBuckets = 24 * 14; private const int BucketDensityPercent = 30; + private const int PercentScale = 100; + private const int MaxEventCount = 5; + private const int GeneratedDimensionValueVariants = 5; + private const uint FnvOffsetBasis = 2166136261u; + private const uint FnvPrime = 16777619u; private static readonly string[] DefaultEventNames = { @@ -40,6 +45,21 @@ public virtual Task GetRowsAsync(AnalyticsDataQuery var eventNames = query.EventNames?.Count > 0 ? query.EventNames : DefaultEventNames; var filterSignature = GetFilterSignature(query); + var events = GenerateEvents(query, from, to, eventNames, filterSignature); + + if (ModuleConstants.SortBy.Count.EqualsIgnoreCase(query.SortBy)) + { + events = AggregateByDimensionTuple(events); + } + + result.TotalCount = events.Count; + result.Events = query.Take > 0 ? events.Skip(query.Skip).Take(query.Take).ToList() : new List(); + + return Task.FromResult(result); + } + + protected virtual IList GenerateEvents(AnalyticsDataQuery query, DateTime from, DateTime to, IList eventNames, string filterSignature) + { var events = new List(); for (var bucketIndex = 0; bucketIndex < MaxHourBuckets; bucketIndex++) @@ -53,41 +73,36 @@ public virtual Task GetRowsAsync(AnalyticsDataQuery foreach (var eventName in eventNames) { var seed = GetStableHash(eventName, bucketIndex.ToString(CultureInfo.InvariantCulture), filterSignature); - if (seed % 100 >= BucketDensityPercent) - { - continue; - } - - var analyticsEvent = AbstractTypeFactory.TryCreateInstance(); - analyticsEvent.EventName = eventName; - analyticsEvent.OccurredAt = bucket; - analyticsEvent.Count = 1 + seed / 100 % 5; - - foreach (var dimensionName in query.DimensionNames ?? Array.Empty()) + if (seed % PercentScale < BucketDensityPercent) { - var value = GetDimensionValue(dimensionName, query.DimensionFilters, seed); - if (value != null) - { - analyticsEvent.Dimensions[dimensionName] = value; - } + events.Add(CreateEvent(query, eventName, bucket, seed)); } - - events.Add(analyticsEvent); } } - if (ModuleConstants.SortBy.Count.EqualsIgnoreCase(query.SortBy)) + return events; + } + + protected virtual AnalyticsEvent CreateEvent(AnalyticsDataQuery query, string eventName, DateTime bucket, int seed) + { + var analyticsEvent = AbstractTypeFactory.TryCreateInstance(); + analyticsEvent.EventName = eventName; + analyticsEvent.OccurredAt = bucket; + analyticsEvent.Count = 1 + seed / PercentScale % MaxEventCount; + + foreach (var dimensionName in query.DimensionNames ?? Array.Empty()) { - events = AggregateByDimensionTuple(events); + var value = GetDimensionValue(dimensionName, query.DimensionFilters, seed); + if (value != null) + { + analyticsEvent.Dimensions[dimensionName] = value; + } } - result.TotalCount = events.Count; - result.Events = query.Take > 0 ? events.Skip(query.Skip).Take(query.Take).ToList() : new List(); - - return Task.FromResult(result); + return analyticsEvent; } - protected virtual List AggregateByDimensionTuple(List events) + protected virtual IList AggregateByDimensionTuple(IList events) { return events .GroupBy(GetDimensionTupleKey) @@ -128,6 +143,11 @@ protected virtual string GetDimensionValue(string dimensionName, IList SampleSearchTerms[seed % SampleSearchTerms.Length], @@ -135,7 +155,7 @@ protected virtual string GetDimensionValue(string dimensionName, IList SampleItemNames[seed % SampleItemNames.Length], ModuleConstants.Dimensions.ItemListName => SampleItemListNames[seed % SampleItemListNames.Length], ModuleConstants.UserDimensions.SessionKind => ModuleConstants.SessionKinds.Self, - _ => $"{dimensionName}-{seed % 5}", + _ => $"{dimensionName}-{seed % GeneratedDimensionValueVariants}", }; } @@ -150,14 +170,14 @@ private static string GetFilterSignature(AnalyticsDataQuery query) // FNV-1a: string.GetHashCode is randomized per process, so it cannot seed deterministic sample data. private static int GetStableHash(params string[] parts) { - var hash = 2166136261u; + var hash = FnvOffsetBasis; foreach (var character in string.Join("|", parts)) { unchecked { hash ^= character; - hash *= 16777619u; + hash *= FnvPrime; } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs index 63bd669..76bf7be 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs @@ -51,7 +51,7 @@ public void PostInitialize(IApplicationBuilder appBuilder) // register settings var settingsRegistrar = serviceProvider.GetRequiredService(); - settingsRegistrar.RegisterSettings(ModuleConstants.Settings.AllSettings, ModuleInfo.Id); + settingsRegistrar.RegisterSettings(ModuleConstants.Settings.General.AllSettings.Concat(ModuleConstants.Settings.DataApi.AllSettings), ModuleInfo.Id); //Register store level settings settingsRegistrar.RegisterSettingsForType(ModuleConstants.Settings.StoreLevelSettings, nameof(Store)); diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs index 94ac07d..2585fd0 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs @@ -40,6 +40,11 @@ public class AnalyticsDiagnosticsServiceTests Stages.ProcessedData, }; + private static readonly string[] ExpectedReportDimensions = { "eventName", "dateHour", "searchTerm" }; + private static readonly string[] ExpectedFilteredDimensions = { "eventName", "customUser:session_kind", "customUser:organization_id" }; + private static readonly string[] ExpectedEventNameFilterValues = { "search", "view_search_results" }; + private static readonly string[] ExpectedEventNameDimensionOnly = { "eventName" }; + private readonly Mock _settingsResolverMock = new(); private readonly Mock _reportClientMock = new(); private readonly List _capturedCompatibilityRequests = new(); @@ -322,13 +327,13 @@ public async Task ReportCompatibility_BuildsRequestFromShapeAndUserDimensions() var request = Assert.Single(_capturedCompatibilityRequests); Assert.Equal($"properties/{PropertyId}", request.Property); Assert.Equal(Compatibility.Incompatible, request.CompatibilityFilter); - Assert.Equal(new[] { "eventName", "dateHour", "searchTerm" }, request.Dimensions.Select(x => x.Name)); + Assert.Equal(ExpectedReportDimensions, request.Dimensions.Select(x => x.Name)); Assert.Equal("eventCount", Assert.Single(request.Metrics).Name); Assert.Equal( - new[] { "eventName", "customUser:session_kind", "customUser:organization_id" }, + ExpectedFilteredDimensions, request.DimensionFilter.AndGroup.Expressions.Select(x => x.Filter.FieldName)); Assert.Equal( - new[] { "search", "view_search_results" }, + ExpectedEventNameFilterValues, request.DimensionFilter.AndGroup.Expressions[0].Filter.InListFilter.Values); } @@ -414,8 +419,8 @@ public async Task Realtime_CustomDimensionsRejected_RetriesEventNameOnly() Assert.Contains("checked event stream only", realtime.Message); Assert.Contains("search=5", realtime.Message); Assert.Equal(2, _capturedRealtimeRequests.Count); - Assert.Equal(new[] { "eventName", "customUser:session_kind", "customUser:organization_id" }, _capturedRealtimeRequests[0].Dimensions.Select(x => x.Name)); - Assert.Equal(new[] { "eventName" }, _capturedRealtimeRequests[1].Dimensions.Select(x => x.Name)); + Assert.Equal(ExpectedFilteredDimensions, _capturedRealtimeRequests[0].Dimensions.Select(x => x.Name)); + Assert.Equal(ExpectedEventNameDimensionOnly, _capturedRealtimeRequests[1].Dimensions.Select(x => x.Name)); } [Fact] diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs index 5cc793a..9af4c01 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs @@ -14,6 +14,14 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; public class GoogleAnalyticsDataSourceTests { + private static readonly string[] ExpectedSearchReportDimensions = { "eventName", "dateHour", "searchTerm", "customUser:organization_id" }; + private static readonly string[] ExpectedSearchCountDimensions = { "eventName", "searchTerm" }; + private static readonly string[] ExpectedItemReportDimensions = { "dateHour", "itemId", "itemName" }; + private static readonly string[] ExpectedItemCountDimensions = { "itemId", "itemName" }; + private static readonly string[] ExpectedSearchEventFilterValues = { "search" }; + private static readonly string[] ExpectedViewItemEventFilterValues = { "view_item" }; + private static readonly string[] ExpectedOrganizationFilterValues = { "org1", "org2" }; + private readonly Mock _reportClientMock = new(); private string _capturedCredential; @@ -48,7 +56,7 @@ public async Task GetRowsAsync_BuildsRunReportRequest() Assert.Equal(5L, _capturedRequest.Offset); Assert.Equal( - new[] { "eventName", "dateHour", "searchTerm", "customUser:organization_id" }, + ExpectedSearchReportDimensions, _capturedRequest.Dimensions.Select(x => x.Name)); Assert.Equal("eventCount", Assert.Single(_capturedRequest.Metrics).Name); @@ -59,9 +67,9 @@ public async Task GetRowsAsync_BuildsRunReportRequest() var expressions = _capturedRequest.DimensionFilter.AndGroup.Expressions; Assert.Equal(3, expressions.Count); Assert.Equal("eventName", expressions[0].Filter.FieldName); - Assert.Equal(new[] { "search" }, expressions[0].Filter.InListFilter.Values); + Assert.Equal(ExpectedSearchEventFilterValues, expressions[0].Filter.InListFilter.Values); Assert.Equal("customUser:organization_id", expressions[1].Filter.FieldName); - Assert.Equal(new[] { "org1", "org2" }, expressions[1].Filter.InListFilter.Values); + Assert.Equal(ExpectedOrganizationFilterValues, expressions[1].Filter.InListFilter.Values); Assert.Equal("searchTerm", expressions[2].Filter.FieldName); var orderBy = Assert.Single(_capturedRequest.OrderBys); @@ -197,7 +205,7 @@ await dataSource.GetRowsAsync(new AnalyticsDataQuery Take = 10, }); - Assert.Equal(new[] { "eventName", "searchTerm" }, _capturedRequest.Dimensions.Select(x => x.Name)); + Assert.Equal(ExpectedSearchCountDimensions, _capturedRequest.Dimensions.Select(x => x.Name)); Assert.Equal("eventCount", Assert.Single(_capturedRequest.Metrics).Name); var orderBy = Assert.Single(_capturedRequest.OrderBys); @@ -223,13 +231,13 @@ await dataSource.GetRowsAsync(new AnalyticsDataQuery Take = 10, }); - Assert.Equal(new[] { "dateHour", "itemId", "itemName" }, _capturedRequest.Dimensions.Select(x => x.Name)); + Assert.Equal(ExpectedItemReportDimensions, _capturedRequest.Dimensions.Select(x => x.Name)); Assert.Equal("itemsViewed", Assert.Single(_capturedRequest.Metrics).Name); var expressions = _capturedRequest.DimensionFilter.AndGroup.Expressions; Assert.Equal(2, expressions.Count); Assert.Equal("eventName", expressions[0].Filter.FieldName); - Assert.Equal(new[] { "view_item" }, expressions[0].Filter.InListFilter.Values); + Assert.Equal(ExpectedViewItemEventFilterValues, expressions[0].Filter.InListFilter.Values); Assert.Equal("customUser:organization_id", expressions[1].Filter.FieldName); var orderBy = Assert.Single(_capturedRequest.OrderBys); @@ -251,7 +259,7 @@ await dataSource.GetRowsAsync(new AnalyticsDataQuery Take = 10, }); - Assert.Equal(new[] { "itemId", "itemName" }, _capturedRequest.Dimensions.Select(x => x.Name)); + Assert.Equal(ExpectedItemCountDimensions, _capturedRequest.Dimensions.Select(x => x.Name)); Assert.Equal("itemsViewed", Assert.Single(_capturedRequest.Metrics).Name); var orderBy = Assert.Single(_capturedRequest.OrderBys); From ffb3a9af136dc6fbb19d5accbb87e6705d94aa6a Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 27 Aug 2026 10:28:14 -0500 Subject: [PATCH 05/36] SampleAnalyticsDataSource removed --- .../ModuleConstants.cs | 10 - .../Models/AnalyticsDataApiSettings.cs | 4 +- .../Services/AnalyticsDiagnosticsService.cs | 13 +- .../Services/AnalyticsService.cs | 12 +- .../Services/AnalyticsSettingsResolver.cs | 1 - .../Services/SampleAnalyticsDataSource.cs | 192 ---------------- .../Module.cs | 1 - .../AnalyticsDiagnosticsServiceTests.cs | 26 +-- .../AnalyticsServiceTests.cs | 33 +-- .../AnalyticsSettingsResolverTests.cs | 9 +- .../SampleAnalyticsDataSourceTests.cs | 205 ------------------ 11 files changed, 21 insertions(+), 485 deletions(-) delete mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs delete mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/SampleAnalyticsDataSourceTests.cs diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index 4bd097d..83f02b8 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -154,14 +154,6 @@ public static class DataApi DefaultValue = DefaultCacheTtlMinutes }; - public static SettingDescriptor SampleDataEnabled { get; } = new SettingDescriptor - { - Name = "GoogleAnalytics4.DataApi.SampleDataEnabled", - GroupName = "Google Analytics 4", - ValueType = SettingValueType.Boolean, - DefaultValue = false - }; - public static IEnumerable AllSettings { get @@ -169,7 +161,6 @@ public static IEnumerable AllSettings yield return PropertyId; yield return ServiceAccountJson; yield return CacheTtlMinutes; - yield return SampleDataEnabled; } } } @@ -184,7 +175,6 @@ public static IEnumerable StoreLevelSettings yield return DataApi.PropertyId; yield return DataApi.ServiceAccountJson; yield return DataApi.CacheTtlMinutes; - yield return DataApi.SampleDataEnabled; } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs index ca8bf3d..4f0ff89 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs @@ -8,7 +8,5 @@ public class AnalyticsDataApiSettings public int CacheTtlMinutes { get; set; } - public bool SampleDataEnabled { get; set; } - - public bool IsGoogleConfigured => !string.IsNullOrEmpty(PropertyId); + public bool IsConfigured => !string.IsNullOrEmpty(PropertyId); } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs index 9a400c0..20848ae 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -66,9 +66,7 @@ public virtual async Task RunAsync(string storeId, A var settings = await CheckConfigurationAsync(storeId, checks); if (settings == null) { - SkipRemainingStages(checks, checks[0].Status == Statuses.Warning - ? "Skipped: sample data mode is active." - : "Skipped: configuration check failed."); + SkipRemainingStages(checks, "Skipped: configuration check failed."); return result; } @@ -107,14 +105,7 @@ protected virtual async Task CheckConfigurationAsync(s return null; } - if (settings.SampleDataEnabled) - { - AddCheck(checks, Stages.Configuration, Statuses.Warning, - "Sample data mode is active — Google Analytics is not exercised."); - return null; - } - - if (!settings.IsGoogleConfigured) + if (!settings.IsConfigured) { AddCheck(checks, Stages.Configuration, Statuses.Failed, "GA4 Data API property id is not configured for the store (GoogleAnalytics4.DataApi.PropertyId)."); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 97003aa..80eabc2 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -19,20 +19,17 @@ public class AnalyticsService : IAnalyticsService private readonly IAnalyticsSettingsResolver _settingsResolver; private readonly IPlatformMemoryCache _platformMemoryCache; private readonly GoogleAnalyticsDataSource _googleAnalyticsDataSource; - private readonly SampleAnalyticsDataSource _sampleAnalyticsDataSource; private readonly ILogger _logger; public AnalyticsService( IAnalyticsSettingsResolver settingsResolver, IPlatformMemoryCache platformMemoryCache, GoogleAnalyticsDataSource googleAnalyticsDataSource, - SampleAnalyticsDataSource sampleAnalyticsDataSource, ILogger logger) { _settingsResolver = settingsResolver; _platformMemoryCache = platformMemoryCache; _googleAnalyticsDataSource = googleAnalyticsDataSource; - _sampleAnalyticsDataSource = sampleAnalyticsDataSource; _logger = logger; } @@ -43,7 +40,7 @@ public virtual async Task IsConfiguredAsync(string storeId) try { var settings = await _settingsResolver.ResolveAsync(storeId); - return settings.SampleDataEnabled || settings.IsGoogleConfigured; + return settings.IsConfigured; } catch (Exception ex) { @@ -142,12 +139,7 @@ public virtual async Task> GetEventSummariesAsync(A protected virtual IAnalyticsDataSource ResolveDataSource(AnalyticsDataApiSettings settings) { - if (settings.SampleDataEnabled) - { - return _sampleAnalyticsDataSource; - } - - return settings.IsGoogleConfigured ? _googleAnalyticsDataSource : null; + return settings.IsConfigured ? _googleAnalyticsDataSource : null; } protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settings, AnalyticsEventCriteriaBase criteria) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs index e9685f2..1eb4495 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs @@ -30,7 +30,6 @@ public virtual async Task ResolveAsync(string storeId) PropertyId = await GetSettingAsync(storeSettings, DataApiSettings.PropertyId), CredentialJson = await GetSettingAsync(storeSettings, DataApiSettings.ServiceAccountJson), CacheTtlMinutes = await GetSettingAsync(storeSettings, DataApiSettings.CacheTtlMinutes), - SampleDataEnabled = await GetSettingAsync(storeSettings, DataApiSettings.SampleDataEnabled), }; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs deleted file mode 100644 index 1145b7a..0000000 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/SampleAnalyticsDataSource.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Threading.Tasks; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; -using VirtoCommerce.Platform.Core.Common; - -namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; - -public class SampleAnalyticsDataSource : IAnalyticsDataSource -{ - private const int MaxHourBuckets = 24 * 14; - private const int BucketDensityPercent = 30; - private const int PercentScale = 100; - private const int MaxEventCount = 5; - private const int GeneratedDimensionValueVariants = 5; - private const uint FnvOffsetBasis = 2166136261u; - private const uint FnvPrime = 16777619u; - - private static readonly string[] DefaultEventNames = - { - ModuleConstants.EventNames.Search, - ModuleConstants.EventNames.ViewSearchResults, - ModuleConstants.EventNames.ViewItem, - ModuleConstants.EventNames.Login, - ModuleConstants.EventNames.SignUp, - ModuleConstants.EventNames.AddToCart, - ModuleConstants.EventNames.Purchase, - }; - - private static readonly string[] SampleSearchTerms = { "drill", "hammer", "socket wrench", "paint", "ladder", "hex bolt" }; - private static readonly string[] SampleItemCodes = { "SAMPLE-0001", "SAMPLE-0002", "SAMPLE-0003", "SAMPLE-0004", "SAMPLE-0005", "SAMPLE-0006" }; - private static readonly string[] SampleItemNames = { "Cordless Drill 18V", "Claw Hammer", "Socket Wrench Set", "Acrylic Paint 5L", "Aluminum Ladder", "Hex Bolt M8" }; - private static readonly string[] SampleItemListNames = { "Search Results", "Related Products", "Category" }; - - public virtual Task GetRowsAsync(AnalyticsDataQuery query) - { - var result = AbstractTypeFactory.TryCreateInstance(); - - var to = TruncateToHour(query.To ?? DateTime.UtcNow); - var from = query.From ?? to.AddHours(-(MaxHourBuckets - 1)); - var eventNames = query.EventNames?.Count > 0 ? query.EventNames : DefaultEventNames; - var filterSignature = GetFilterSignature(query); - - var events = GenerateEvents(query, from, to, eventNames, filterSignature); - - if (ModuleConstants.SortBy.Count.EqualsIgnoreCase(query.SortBy)) - { - events = AggregateByDimensionTuple(events); - } - - result.TotalCount = events.Count; - result.Events = query.Take > 0 ? events.Skip(query.Skip).Take(query.Take).ToList() : new List(); - - return Task.FromResult(result); - } - - protected virtual IList GenerateEvents(AnalyticsDataQuery query, DateTime from, DateTime to, IList eventNames, string filterSignature) - { - var events = new List(); - - for (var bucketIndex = 0; bucketIndex < MaxHourBuckets; bucketIndex++) - { - var bucket = to.AddHours(-bucketIndex); - if (bucket < from) - { - break; - } - - foreach (var eventName in eventNames) - { - var seed = GetStableHash(eventName, bucketIndex.ToString(CultureInfo.InvariantCulture), filterSignature); - if (seed % PercentScale < BucketDensityPercent) - { - events.Add(CreateEvent(query, eventName, bucket, seed)); - } - } - } - - return events; - } - - protected virtual AnalyticsEvent CreateEvent(AnalyticsDataQuery query, string eventName, DateTime bucket, int seed) - { - var analyticsEvent = AbstractTypeFactory.TryCreateInstance(); - analyticsEvent.EventName = eventName; - analyticsEvent.OccurredAt = bucket; - analyticsEvent.Count = 1 + seed / PercentScale % MaxEventCount; - - foreach (var dimensionName in query.DimensionNames ?? Array.Empty()) - { - var value = GetDimensionValue(dimensionName, query.DimensionFilters, seed); - if (value != null) - { - analyticsEvent.Dimensions[dimensionName] = value; - } - } - - return analyticsEvent; - } - - protected virtual IList AggregateByDimensionTuple(IList events) - { - return events - .GroupBy(GetDimensionTupleKey) - .Select(group => - { - var first = group.First(); - - var analyticsEvent = AbstractTypeFactory.TryCreateInstance(); - analyticsEvent.EventName = first.EventName; - analyticsEvent.Count = group.Sum(x => x.Count); - - foreach (var dimension in first.Dimensions) - { - analyticsEvent.Dimensions[dimension.Key] = dimension.Value; - } - - return analyticsEvent; - }) - .OrderByDescending(x => x.Count) - .ThenBy(GetDimensionTupleKey, StringComparer.Ordinal) - .ToList(); - } - - protected virtual string GetDimensionTupleKey(AnalyticsEvent analyticsEvent) - { - var dimensions = string.Join(",", analyticsEvent.Dimensions - .OrderBy(x => x.Key, StringComparer.Ordinal) - .Select(x => $"{x.Key}={x.Value}")); - - return $"{analyticsEvent.EventName}|{dimensions}"; - } - - protected virtual string GetDimensionValue(string dimensionName, IList filters, int seed) - { - var filter = filters?.FirstOrDefault(x => x.DimensionName == dimensionName && x.Values?.Count > 0); - if (filter != null) - { - return filter.Values[seed % filter.Values.Count]; - } - - return GetGeneratedDimensionValue(dimensionName, seed); - } - - protected virtual string GetGeneratedDimensionValue(string dimensionName, int seed) - { - return dimensionName switch - { - ModuleConstants.Dimensions.SearchTerm => SampleSearchTerms[seed % SampleSearchTerms.Length], - ModuleConstants.Dimensions.ItemId => SampleItemCodes[seed % SampleItemCodes.Length], - ModuleConstants.Dimensions.ItemName => SampleItemNames[seed % SampleItemNames.Length], - ModuleConstants.Dimensions.ItemListName => SampleItemListNames[seed % SampleItemListNames.Length], - ModuleConstants.UserDimensions.SessionKind => ModuleConstants.SessionKinds.Self, - _ => $"{dimensionName}-{seed % GeneratedDimensionValueVariants}", - }; - } - - private static string GetFilterSignature(AnalyticsDataQuery query) - { - return string.Join(";", (query.DimensionFilters ?? Array.Empty()) - .Where(x => !string.IsNullOrEmpty(x.DimensionName)) - .OrderBy(x => x.DimensionName, StringComparer.Ordinal) - .Select(x => $"{x.DimensionName}={string.Join(",", x.Values ?? Array.Empty())}")); - } - - // FNV-1a: string.GetHashCode is randomized per process, so it cannot seed deterministic sample data. - private static int GetStableHash(params string[] parts) - { - var hash = FnvOffsetBasis; - - foreach (var character in string.Join("|", parts)) - { - unchecked - { - hash ^= character; - hash *= FnvPrime; - } - } - - return (int)(hash % int.MaxValue); - } - - private static DateTime TruncateToHour(DateTime value) - { - var utcValue = value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : DateTime.SpecifyKind(value, DateTimeKind.Utc); - return new DateTime(utcValue.Year, utcValue.Month, utcValue.Day, utcValue.Hour, 0, 0, DateTimeKind.Utc); - } -} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs index 76bf7be..4af5446 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs @@ -22,7 +22,6 @@ public void Initialize(IServiceCollection serviceCollection) serviceCollection.AddTransient(); serviceCollection.AddSingleton(); serviceCollection.AddTransient(); - serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs index 2585fd0..7fc8a11 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs @@ -97,26 +97,6 @@ public async Task RunAsync_NullRequest_RunsWithDefaults() Assert.Single(Assert.Single(_capturedRealtimeRequests).Dimensions); } - [Fact] - public async Task RunAsync_SampleMode_WarnsAndSkipsGoogleStages() - { - SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId, SampleDataEnabled = true }); - var service = CreateService(); - - var result = await service.RunAsync(StoreId, CreateRequest()); - - AssertStageOrder(result); - var configuration = GetCheck(result, Stages.Configuration); - Assert.Equal(Statuses.Warning, configuration.Status); - Assert.Contains("Sample data mode", configuration.Message); - Assert.All(result.Checks.Skip(1), x => - { - Assert.Equal(Statuses.Skipped, x.Status); - Assert.Contains("sample data mode", x.Message); - }); - _reportClientMock.VerifyNoOtherCalls(); - } - [Fact] public async Task RunAsync_NoPropertyId_FailsConfigurationAndSkipsRest() { @@ -129,7 +109,11 @@ public async Task RunAsync_NoPropertyId_FailsConfigurationAndSkipsRest() var configuration = GetCheck(result, Stages.Configuration); Assert.Equal(Statuses.Failed, configuration.Status); Assert.Contains("PropertyId", configuration.Message); - Assert.All(result.Checks.Skip(1), x => Assert.Equal(Statuses.Skipped, x.Status)); + Assert.All(result.Checks.Skip(1), x => + { + Assert.Equal(Statuses.Skipped, x.Status); + Assert.Contains("configuration check failed", x.Message); + }); _reportClientMock.VerifyNoOtherCalls(); } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index fe80237..c4003b0 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -32,18 +32,16 @@ public AnalyticsServiceTests() } [Theory] - [InlineData("123456", null, false, true)] - [InlineData("123456", "{}", false, true)] - [InlineData(null, "{}", false, false)] - [InlineData(null, null, true, true)] - [InlineData(null, null, false, false)] - public async Task IsConfiguredAsync_Matrix(string propertyId, string credentialJson, bool sampleDataEnabled, bool expected) + [InlineData("123456", null, true)] + [InlineData("123456", "{}", true)] + [InlineData(null, "{}", false)] + [InlineData(null, null, false)] + public async Task IsConfiguredAsync_Matrix(string propertyId, string credentialJson, bool expected) { var service = CreateService(new AnalyticsDataApiSettings { PropertyId = propertyId, CredentialJson = credentialJson, - SampleDataEnabled = sampleDataEnabled, }); Assert.Equal(expected, await service.IsConfiguredAsync(StoreId)); @@ -179,20 +177,6 @@ public async Task SearchEventsAsync_FailureCacheExpires_RecoversQuickly() _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } - [Fact] - public async Task SearchEventsAsync_SampleDataEnabled_UsesSampleSource() - { - var service = CreateService(new AnalyticsDataApiSettings { SampleDataEnabled = true }); - - var criteria = CreateSearchCriteria(); - criteria.Take = 50; - var result = await service.SearchEventsAsync(criteria); - - Assert.True(result.TotalCount > 0); - Assert.NotEmpty(result.Events); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); - } - [Fact] public async Task GetEventSummariesAsync_AggregatesPerRequestedEventName() { @@ -268,8 +252,8 @@ private AnalyticsService CreateService(AnalyticsDataApiSettings settings = null, var logger = new Mock>().Object; return failureCacheTtl == null - ? new AnalyticsService(_settingsResolverMock.Object, platformMemoryCache, _googleDataSourceMock.Object, new SampleAnalyticsDataSource(), logger) - : new ShortFailureTtlAnalyticsService(failureCacheTtl.Value, _settingsResolverMock.Object, platformMemoryCache, _googleDataSourceMock.Object, new SampleAnalyticsDataSource(), logger); + ? new AnalyticsService(_settingsResolverMock.Object, platformMemoryCache, _googleDataSourceMock.Object, logger) + : new ShortFailureTtlAnalyticsService(failureCacheTtl.Value, _settingsResolverMock.Object, platformMemoryCache, _googleDataSourceMock.Object, logger); } private AnalyticsService CreateGoogleConfiguredService(TimeSpan? failureCacheTtl = null) @@ -318,9 +302,8 @@ public ShortFailureTtlAnalyticsService( IAnalyticsSettingsResolver settingsResolver, IPlatformMemoryCache platformMemoryCache, GoogleAnalyticsDataSource googleAnalyticsDataSource, - SampleAnalyticsDataSource sampleAnalyticsDataSource, ILogger logger) - : base(settingsResolver, platformMemoryCache, googleAnalyticsDataSource, sampleAnalyticsDataSource, logger) + : base(settingsResolver, platformMemoryCache, googleAnalyticsDataSource, logger) { _failureCacheTtl = failureCacheTtl; } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs index e4c7ce3..c34c03b 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs @@ -31,8 +31,7 @@ public async Task ResolveAsync_MapsStoreSettings() SetupStore(CreateStore( (ModuleConstants.Settings.DataApi.PropertyId.Name, "123456"), (ModuleConstants.Settings.DataApi.ServiceAccountJson.Name, "{}"), - (ModuleConstants.Settings.DataApi.CacheTtlMinutes.Name, 15), - (ModuleConstants.Settings.DataApi.SampleDataEnabled.Name, true))); + (ModuleConstants.Settings.DataApi.CacheTtlMinutes.Name, 15))); var resolver = CreateResolver(); var settings = await resolver.ResolveAsync(StoreId); @@ -40,8 +39,7 @@ public async Task ResolveAsync_MapsStoreSettings() Assert.Equal("123456", settings.PropertyId); Assert.Equal("{}", settings.CredentialJson); Assert.Equal(15, settings.CacheTtlMinutes); - Assert.True(settings.SampleDataEnabled); - Assert.True(settings.IsGoogleConfigured); + Assert.True(settings.IsConfigured); } [Fact] @@ -90,8 +88,7 @@ public async Task ResolveAsync_NothingConfigured_ReturnsUnconfiguredSettings() Assert.Null(settings.PropertyId); Assert.Null(settings.CredentialJson); - Assert.False(settings.SampleDataEnabled); - Assert.False(settings.IsGoogleConfigured); + Assert.False(settings.IsConfigured); } private AnalyticsSettingsResolver CreateResolver() diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/SampleAnalyticsDataSourceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/SampleAnalyticsDataSourceTests.cs deleted file mode 100644 index 72f47d1..0000000 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/SampleAnalyticsDataSourceTests.cs +++ /dev/null @@ -1,205 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; -using Xunit; - -namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; - -public class SampleAnalyticsDataSourceTests -{ - private static readonly DateTime To = new(2026, 8, 25, 12, 0, 0, DateTimeKind.Utc); - - private readonly SampleAnalyticsDataSource _dataSource = new(); - - [Fact] - public async Task GetRowsAsync_SameQuery_ReturnsIdenticalData() - { - var first = await _dataSource.GetRowsAsync(CreateQuery()); - var second = await _dataSource.GetRowsAsync(CreateQuery()); - - Assert.True(first.TotalCount > 0); - Assert.Equal(first.TotalCount, second.TotalCount); - Assert.Equal(first.Events.Select(Describe), second.Events.Select(Describe)); - } - - [Fact] - public async Task GetRowsAsync_HonorsEventNames() - { - var query = CreateQuery(); - query.EventNames = new List { ModuleConstants.EventNames.Login }; - - var result = await _dataSource.GetRowsAsync(query); - - Assert.NotEmpty(result.Events); - Assert.All(result.Events, x => Assert.Equal(ModuleConstants.EventNames.Login, x.EventName)); - } - - [Fact] - public async Task GetRowsAsync_HonorsDimensionFilterValues() - { - var query = CreateQuery(); - query.DimensionNames = new List { ModuleConstants.UserDimensions.OrganizationId }; - query.DimensionFilters = new List - { - new() { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = new List { "org1" } }, - }; - - var result = await _dataSource.GetRowsAsync(query); - - Assert.NotEmpty(result.Events); - Assert.All(result.Events, x => Assert.Equal("org1", x.Dimensions[ModuleConstants.UserDimensions.OrganizationId])); - } - - [Fact] - public async Task GetRowsAsync_DifferentFilters_ProduceDifferentSeries() - { - var org1Query = CreateQuery(); - org1Query.DimensionFilters = new List - { - new() { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = new List { "org1" } }, - }; - - var org2Query = CreateQuery(); - org2Query.DimensionFilters = new List - { - new() { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = new List { "org2" } }, - }; - - var org1Result = await _dataSource.GetRowsAsync(org1Query); - var org2Result = await _dataSource.GetRowsAsync(org2Query); - - Assert.NotEqual(org1Result.Events.Select(Describe), org2Result.Events.Select(Describe)); - } - - [Fact] - public async Task GetRowsAsync_HonorsFromToBounds() - { - var query = CreateQuery(); - query.From = To.AddHours(-5); - - var result = await _dataSource.GetRowsAsync(query); - - Assert.NotEmpty(result.Events); - Assert.All(result.Events, x => - { - Assert.True(x.OccurredAt >= query.From); - Assert.True(x.OccurredAt <= To); - }); - } - - [Fact] - public async Task GetRowsAsync_PagingSlicesWithoutChangingTotalCount() - { - var fullResult = await _dataSource.GetRowsAsync(CreateQuery()); - - var pageQuery = CreateQuery(); - pageQuery.Skip = 5; - pageQuery.Take = 10; - var pageResult = await _dataSource.GetRowsAsync(pageQuery); - - Assert.Equal(fullResult.TotalCount, pageResult.TotalCount); - Assert.Equal(10, pageResult.Events.Count); - Assert.Equal(fullResult.Events.Skip(5).Take(10).Select(Describe), pageResult.Events.Select(Describe)); - } - - [Fact] - public async Task GetRowsAsync_TakeZero_ReturnsCountOnly() - { - var query = CreateQuery(); - query.Take = 0; - - var result = await _dataSource.GetRowsAsync(query); - - Assert.True(result.TotalCount > 0); - Assert.Empty(result.Events); - } - - [Fact] - public async Task GetRowsAsync_SortsByOccurredAtDescending() - { - var result = await _dataSource.GetRowsAsync(CreateQuery()); - - var occurredAts = result.Events.Select(x => x.OccurredAt).ToList(); - Assert.Equal(occurredAts.OrderByDescending(x => x), occurredAts); - } - - [Fact] - public async Task GetRowsAsync_CountSort_AggregatesPerDimensionTuple() - { - var dateQuery = CreateQuery(); - dateQuery.DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm }; - - var countQuery = CreateQuery(); - countQuery.DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm }; - countQuery.SortBy = ModuleConstants.SortBy.Count; - - var dateResult = await _dataSource.GetRowsAsync(dateQuery); - var countResult = await _dataSource.GetRowsAsync(countQuery); - - Assert.NotEmpty(countResult.Events); - Assert.True(countResult.TotalCount < dateResult.TotalCount); - Assert.Equal(dateResult.Events.Sum(x => x.Count), countResult.Events.Sum(x => x.Count)); - Assert.All(countResult.Events, x => Assert.Null(x.OccurredAt)); - - var tuples = countResult.Events.Select(x => $"{x.EventName}|{x.Dimensions[ModuleConstants.Dimensions.SearchTerm]}").ToList(); - Assert.Equal(tuples.Distinct().Count(), tuples.Count); - - var counts = countResult.Events.Select(x => x.Count).ToList(); - Assert.Equal(counts.OrderByDescending(x => x), counts); - } - - [Fact] - public async Task GetRowsAsync_CountSort_IsDeterministic() - { - var first = await _dataSource.GetRowsAsync(CreateCountQuery()); - var second = await _dataSource.GetRowsAsync(CreateCountQuery()); - - Assert.True(first.TotalCount > 0); - Assert.Equal(first.Events.Select(Describe), second.Events.Select(Describe)); - } - - [Fact] - public async Task GetRowsAsync_CountSort_PagingSlicesWithoutChangingTotalCount() - { - var fullResult = await _dataSource.GetRowsAsync(CreateCountQuery()); - - var pageQuery = CreateCountQuery(); - pageQuery.Skip = 2; - pageQuery.Take = 3; - var pageResult = await _dataSource.GetRowsAsync(pageQuery); - - Assert.Equal(fullResult.TotalCount, pageResult.TotalCount); - Assert.Equal(fullResult.Events.Skip(2).Take(3).Select(Describe), pageResult.Events.Select(Describe)); - } - - private static AnalyticsDataQuery CreateCountQuery() - { - var query = CreateQuery(); - query.DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm }; - query.SortBy = ModuleConstants.SortBy.Count; - return query; - } - - private static AnalyticsDataQuery CreateQuery() - { - return new AnalyticsDataQuery - { - To = To, - Take = 10_000, - }; - } - - private static string Describe(AnalyticsEvent analyticsEvent) - { - var dimensions = string.Join(",", analyticsEvent.Dimensions - .OrderBy(x => x.Key, StringComparer.Ordinal) - .Select(x => $"{x.Key}={x.Value}")); - - return $"{analyticsEvent.EventName}|{analyticsEvent.OccurredAt:O}|{analyticsEvent.Count}|{dimensions}"; - } -} From a1930f24f8e017a7e4bf97a39babc37c624d1ed1 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 27 Aug 2026 10:39:20 -0500 Subject: [PATCH 06/36] Obsolete settings removed --- .../ModuleConstants.cs | 9 -- .../Models/AnalyticsDataApiSettings.cs | 2 - .../Models/AnalyticsDataQuery.cs | 2 - .../Services/AnalyticsDiagnosticsService.cs | 53 ++++------- .../Services/AnalyticsService.cs | 1 - .../Services/AnalyticsSettingsResolver.cs | 1 - .../Services/GoogleAnalyticsDataSource.cs | 4 +- .../Services/GoogleAnalyticsReportClient.cs | 41 +++------ .../Services/IGoogleAnalyticsReportClient.cs | 10 +- ...GoogleEcommerceAnalyticsModule.Data.csproj | 1 - .../AnalyticsDiagnosticsServiceTests.cs | 91 ++++++------------- .../AnalyticsServiceTests.cs | 18 ++-- .../AnalyticsSettingsResolverTests.cs | 3 - .../GoogleAnalyticsDataSourceTests.cs | 11 +-- .../GoogleAnalyticsReportClientTests.cs | 79 ++++++---------- 15 files changed, 101 insertions(+), 225 deletions(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index 83f02b8..d2a3313 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -139,13 +139,6 @@ public static class DataApi ValueType = SettingValueType.ShortText }; - public static SettingDescriptor ServiceAccountJson { get; } = new SettingDescriptor - { - Name = "GoogleAnalytics4.DataApi.ServiceAccountJson", - GroupName = "Google Analytics 4", - ValueType = SettingValueType.SecureString - }; - public static SettingDescriptor CacheTtlMinutes { get; } = new SettingDescriptor { Name = "GoogleAnalytics4.DataApi.CacheTtlMinutes", @@ -159,7 +152,6 @@ public static IEnumerable AllSettings get { yield return PropertyId; - yield return ServiceAccountJson; yield return CacheTtlMinutes; } } @@ -173,7 +165,6 @@ public static IEnumerable StoreLevelSettings yield return General.MeasurementId; yield return General.GtmContainerId; yield return DataApi.PropertyId; - yield return DataApi.ServiceAccountJson; yield return DataApi.CacheTtlMinutes; } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs index 4f0ff89..cc54941 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs @@ -4,8 +4,6 @@ public class AnalyticsDataApiSettings { public string PropertyId { get; set; } - public string CredentialJson { get; set; } - public int CacheTtlMinutes { get; set; } public bool IsConfigured => !string.IsNullOrEmpty(PropertyId); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs index 17f1e18..96f7412 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs @@ -9,8 +9,6 @@ public class AnalyticsDataQuery { public string PropertyId { get; set; } - public string CredentialJson { get; set; } - public IList EventNames { get; set; } = new List(); public IList DimensionFilters { get; set; } = new List(); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs index 20848ae..a1c05f1 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -6,8 +6,6 @@ using Google.Analytics.Data.V1Beta; using Google.Api.Gax.Grpc; using Grpc.Core; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; @@ -24,7 +22,7 @@ public class AnalyticsDiagnosticsService : IAnalyticsDiagnosticsService { private const string UserDimensionPrefix = "customUser:"; private const string EventCountMetric = "eventCount"; - private const string CredentialTypeProperty = "type"; + private const string ApplicationDefaultCredentials = "Application Default Credentials"; private const string ProbeFilterValue = "diagnostics-probe"; private const string ProcessedDataStartDate = "7daysAgo"; private const string ProcessedDataEndDate = "today"; @@ -70,7 +68,7 @@ public virtual async Task RunAsync(string storeId, A return result; } - if (!await CheckCredentialsAsync(settings, checks)) + if (!await CheckCredentialsAsync(checks)) { SkipRemainingStages(checks, "Skipped: credential resolution failed."); return result; @@ -113,24 +111,26 @@ protected virtual async Task CheckConfigurationAsync(s } AddCheck(checks, Stages.Configuration, Statuses.Passed, - $"Property {settings.PropertyId} configured; credential: {DescribeCredentialSource(settings.CredentialJson)}."); + $"Property {settings.PropertyId} configured; credential: {DescribeCredentialSource()}."); return settings; } - protected virtual async Task CheckCredentialsAsync(AnalyticsDataApiSettings settings, IList checks) + protected virtual async Task CheckCredentialsAsync(IList checks) { try { - await _reportClient.ValidateCredentialAsync(settings.CredentialJson); - AddCheck(checks, Stages.Credentials, Statuses.Passed, "Credential resolved successfully."); + await _reportClient.ValidateCredentialAsync(); + AddCheck(checks, Stages.Credentials, Statuses.Passed, + $"{ApplicationDefaultCredentials} resolved and scoped for read-only Google Analytics access."); return true; } catch (Exception ex) { - var message = string.IsNullOrWhiteSpace(settings.CredentialJson) - ? "Application Default Credentials are not available — set GoogleAnalytics4.DataApi.ServiceAccountJson or provide ADC (GOOGLE_APPLICATION_CREDENTIALS)." - : "The configured credential JSON cannot be used — check GoogleAnalytics4.DataApi.ServiceAccountJson for malformed or incomplete JSON."; - AddCheck(checks, Stages.Credentials, Statuses.Failed, message, ex.Message); + AddCheck(checks, Stages.Credentials, Statuses.Failed, + $"{ApplicationDefaultCredentials} are not available — point GOOGLE_APPLICATION_CREDENTIALS at a service account key file, " + + "make sure the workload identity federation or metadata-server identity is reachable, " + + "or run 'gcloud auth application-default login' for local development.", + ex.Message); return false; } } @@ -139,7 +139,7 @@ protected virtual async Task CheckApiAccessAsync(AnalyticsDataApiSetti { try { - var metadata = await _reportClient.GetMetadataAsync(settings.CredentialJson, settings.PropertyId); + var metadata = await _reportClient.GetMetadataAsync(settings.PropertyId); if (metadata == null) { AddCheck(checks, Stages.ApiAccess, Statuses.Failed, "Google Analytics Data API returned no metadata."); @@ -242,7 +242,7 @@ protected virtual async Task CheckReportCompatibilityAsync(AnalyticsDataApiSetti foreach (var shape in shapes) { var compatibilityRequest = BuildCompatibilityRequest(settings.PropertyId, shape, userDimensionNames); - var response = await _reportClient.CheckCompatibilityAsync(settings.CredentialJson, compatibilityRequest); + var response = await _reportClient.CheckCompatibilityAsync(compatibilityRequest); var incompatibleFields = GetIncompatibleFields(response, GetRequestedFieldNames(compatibilityRequest)); if (incompatibleFields.Count > 0) @@ -294,13 +294,13 @@ protected virtual async Task CheckRealtimeAsync(AnalyticsDataApiSettings setting try { - response = await _reportClient.RunRealtimeReportAsync(settings.CredentialJson, + response = await _reportClient.RunRealtimeReportAsync( BuildRealtimeRequest(settings.PropertyId, userDimensionNames)); } catch (RpcException ex) when (checkedCustomDimensions && ex.StatusCode == StatusCode.InvalidArgument) { checkedCustomDimensions = false; - response = await _reportClient.RunRealtimeReportAsync(settings.CredentialJson, + response = await _reportClient.RunRealtimeReportAsync( BuildRealtimeRequest(settings.PropertyId, new List())); } @@ -329,7 +329,7 @@ protected virtual async Task CheckProcessedDataAsync(AnalyticsDataApiSettings se { try { - var response = await _reportClient.RunReportAsync(settings.CredentialJson, BuildProcessedDataRequest(settings.PropertyId)); + var response = await _reportClient.RunReportAsync(BuildProcessedDataRequest(settings.PropertyId)); var eventCounts = AggregateEventCounts(response.DimensionHeaders, response.Rows); if (eventCounts.Count == 0) @@ -511,24 +511,9 @@ protected virtual string DescribeMissingRequestedEvents(AnalyticsDiagnosticsRequ return missingNames.Count > 0 ? $" Requested events not seen: {string.Join(", ", missingNames)}." : string.Empty; } - protected virtual string DescribeCredentialSource(string credentialJson) + protected virtual string DescribeCredentialSource() { - if (string.IsNullOrWhiteSpace(credentialJson)) - { - return "Application Default Credentials"; - } - - string credentialType = null; - try - { - credentialType = JObject.Parse(credentialJson)[CredentialTypeProperty]?.Value(); - } - catch (JsonException) - { - // Malformed JSON is diagnosed by the credentials stage; the source kind is still reported here. - } - - return string.IsNullOrEmpty(credentialType) ? "JSON from setting" : $"{credentialType} from setting"; + return ApplicationDefaultCredentials; } protected virtual string DescribeError(Exception exception) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 80eabc2..9bd6188 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -147,7 +147,6 @@ protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settin return new AnalyticsDataQuery { PropertyId = settings.PropertyId, - CredentialJson = settings.CredentialJson, EventNames = criteria.EventNames, DimensionFilters = criteria.DimensionFilters, From = criteria.From, diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs index 1eb4495..db91379 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs @@ -28,7 +28,6 @@ public virtual async Task ResolveAsync(string storeId) return new AnalyticsDataApiSettings { PropertyId = await GetSettingAsync(storeSettings, DataApiSettings.PropertyId), - CredentialJson = await GetSettingAsync(storeSettings, DataApiSettings.ServiceAccountJson), CacheTtlMinutes = await GetSettingAsync(storeSettings, DataApiSettings.CacheTtlMinutes), }; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs index 5330bef..e0acbe7 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs @@ -43,7 +43,7 @@ public virtual Task GetRowsAsync(AnalyticsDataQuery protected virtual async Task GetEventScopedRowsAsync(AnalyticsDataQuery query) { - var response = await _reportClient.RunReportAsync(query.CredentialJson, BuildEventReportRequest(query)); + var response = await _reportClient.RunReportAsync(BuildEventReportRequest(query)); return MapResponse(response, query); } @@ -51,7 +51,7 @@ protected virtual async Task GetEventScopedRowsAsync // kept as a separate path so it can be reworked without touching the main query path. protected virtual async Task GetItemScopedRowsAsync(AnalyticsDataQuery query) { - var response = await _reportClient.RunReportAsync(query.CredentialJson, BuildItemReportRequest(query)); + var response = await _reportClient.RunReportAsync(BuildItemReportRequest(query)); var result = MapResponse(response, query); var eventName = query.EventNames?.Count == 1 ? query.EventNames[0] : null; diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs index 116e4d6..a6106e7 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs @@ -1,74 +1,61 @@ using System.Threading.Tasks; using Google.Analytics.Data.V1Beta; using Google.Apis.Auth.OAuth2; -using Newtonsoft.Json.Linq; namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; public class GoogleAnalyticsReportClient : IGoogleAnalyticsReportClient { - private const string CredentialTypeProperty = "type"; - private static readonly string[] AnalyticsReadOnlyScopes = { "https://www.googleapis.com/auth/analytics.readonly" }; - public virtual Task ValidateCredentialAsync(string credentialJson) + public virtual Task ValidateCredentialAsync() { - return ResolveCredentialAsync(credentialJson); + return ResolveCredentialAsync(); } - public virtual async Task GetMetadataAsync(string credentialJson, string propertyId) + public virtual async Task GetMetadataAsync(string propertyId) { - var client = await CreateClientAsync(credentialJson); + var client = await CreateClientAsync(); return await client.GetMetadataAsync(new GetMetadataRequest { Name = $"properties/{propertyId}/metadata" }); } - public virtual async Task RunReportAsync(string credentialJson, RunReportRequest request) + public virtual async Task RunReportAsync(RunReportRequest request) { - var client = await CreateClientAsync(credentialJson); + var client = await CreateClientAsync(); return await client.RunReportAsync(request); } - public virtual async Task RunRealtimeReportAsync(string credentialJson, RunRealtimeReportRequest request) + public virtual async Task RunRealtimeReportAsync(RunRealtimeReportRequest request) { - var client = await CreateClientAsync(credentialJson); + var client = await CreateClientAsync(); return await client.RunRealtimeReportAsync(request); } - public virtual async Task CheckCompatibilityAsync(string credentialJson, CheckCompatibilityRequest request) + public virtual async Task CheckCompatibilityAsync(CheckCompatibilityRequest request) { - var client = await CreateClientAsync(credentialJson); + var client = await CreateClientAsync(); return await client.CheckCompatibilityAsync(request); } - protected virtual async Task CreateClientAsync(string credentialJson) + protected virtual async Task CreateClientAsync() { var builder = new BetaAnalyticsDataClientBuilder { - GoogleCredential = await ResolveCredentialAsync(credentialJson), + GoogleCredential = await ResolveCredentialAsync(), }; return await builder.BuildAsync(); } - protected virtual async Task ResolveCredentialAsync(string credentialJson) + protected virtual async Task ResolveCredentialAsync() { - var credential = string.IsNullOrWhiteSpace(credentialJson) - ? await GetApplicationDefaultCredentialAsync() - : CreateCredentialFromJson(credentialJson); + var credential = await GetApplicationDefaultCredentialAsync(); return credential.IsCreateScopedRequired ? credential.CreateScoped(AnalyticsReadOnlyScopes) : credential; } - // Supports both service_account keys and Workload Identity Federation external_account configurations; - // the declared credential type is passed through because CredentialFactory has no multi-type overload. - protected virtual GoogleCredential CreateCredentialFromJson(string credentialJson) - { - var credentialType = JObject.Parse(credentialJson)[CredentialTypeProperty]?.Value(); - return CredentialFactory.FromJson(credentialJson, credentialType); - } - protected virtual Task GetApplicationDefaultCredentialAsync() { return GoogleCredential.GetApplicationDefaultAsync(); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs index c2ff89b..1e896fa 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs @@ -5,13 +5,13 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; public interface IGoogleAnalyticsReportClient { - Task ValidateCredentialAsync(string credentialJson); + Task ValidateCredentialAsync(); - Task GetMetadataAsync(string credentialJson, string propertyId); + Task GetMetadataAsync(string propertyId); - Task RunReportAsync(string credentialJson, RunReportRequest request); + Task RunReportAsync(RunReportRequest request); - Task RunRealtimeReportAsync(string credentialJson, RunRealtimeReportRequest request); + Task RunRealtimeReportAsync(RunRealtimeReportRequest request); - Task CheckCompatibilityAsync(string credentialJson, CheckCompatibilityRequest request); + Task CheckCompatibilityAsync(CheckCompatibilityRequest request); } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj index ff64d6e..f15e907 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj @@ -13,7 +13,6 @@ - diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs index 7fc8a11..8824ee0 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs @@ -64,9 +64,9 @@ public async Task RunAsync_HappyPath_AllStagesPassInStableOrder() var configuration = GetCheck(result, Stages.Configuration); Assert.Contains(PropertyId, configuration.Message); - Assert.Contains("service_account from setting", configuration.Message); - Assert.DoesNotContain("private", configuration.Message); + Assert.Contains("Application Default Credentials", configuration.Message); + Assert.Contains("Application Default Credentials", GetCheck(result, Stages.Credentials).Message); Assert.Contains("session_kind", GetCheck(result, Stages.CustomDimensions).Message); Assert.Contains("searchTerms", GetCheck(result, Stages.ReportCompatibility).Message); @@ -135,37 +135,11 @@ public async Task RunAsync_ResolverThrows_FailsConfigurationWithDetail() } [Fact] - public async Task RunAsync_AdcCredential_ReportsAdcSource() + public async Task Credentials_AdcNotFound_FailsWithOperatorAdviceAndSkipsRest() { - SetupHappyGooglePath(); - SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId }); - var service = CreateService(); - - var result = await service.RunAsync(StoreId, CreateRequest()); - - Assert.Contains("Application Default Credentials", GetCheck(result, Stages.Configuration).Message); - } - - [Fact] - public async Task RunAsync_UnparseableCredentialJson_ReportsSourceWithoutContent() - { - SetupHappyGooglePath(); - SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId, CredentialJson = "{secret-not-json" }); - var service = CreateService(); - - var result = await service.RunAsync(StoreId, CreateRequest()); - - var configuration = GetCheck(result, Stages.Configuration); - Assert.Contains("JSON from setting", configuration.Message); - Assert.DoesNotContain("secret", configuration.Message); - } - - [Fact] - public async Task Credentials_AdcNotFound_FailsAndSkipsRest() - { - SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId }); + SetupGoogleSettings(); _reportClientMock - .Setup(x => x.ValidateCredentialAsync(It.IsAny())) + .Setup(x => x.ValidateCredentialAsync()) .ThrowsAsync(new InvalidOperationException("The Application Default Credentials are not available.")); var service = CreateService(); @@ -175,25 +149,12 @@ public async Task Credentials_AdcNotFound_FailsAndSkipsRest() var credentials = GetCheck(result, Stages.Credentials); Assert.Equal(Statuses.Failed, credentials.Status); Assert.Contains("Application Default Credentials", credentials.Message); + Assert.Contains("GOOGLE_APPLICATION_CREDENTIALS", credentials.Message); + Assert.Contains("workload identity", credentials.Message); + Assert.Contains("gcloud auth application-default login", credentials.Message); + Assert.Equal("The Application Default Credentials are not available.", credentials.Detail); Assert.All(result.Checks.Skip(2), x => Assert.Equal(Statuses.Skipped, x.Status)); - _reportClientMock.Verify(x => x.GetMetadataAsync(It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task Credentials_MalformedConfiguredJson_MessagePointsAtSetting() - { - SetupGoogleSettings(); - _reportClientMock - .Setup(x => x.ValidateCredentialAsync(It.IsAny())) - .ThrowsAsync(new InvalidOperationException("Error creating credential from JSON.")); - var service = CreateService(); - - var result = await service.RunAsync(StoreId, CreateRequest()); - - var credentials = GetCheck(result, Stages.Credentials); - Assert.Equal(Statuses.Failed, credentials.Status); - Assert.Contains("GoogleAnalytics4.DataApi.ServiceAccountJson", credentials.Message); - Assert.Equal("Error creating credential from JSON.", credentials.Detail); + _reportClientMock.Verify(x => x.GetMetadataAsync(It.IsAny()), Times.Never); } [Fact] @@ -359,7 +320,7 @@ public async Task ReportCompatibility_ClientThrows_FailsStageButLiveDataStillRun { SetupHappyGooglePath(); _reportClientMock - .Setup(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny())) + .Setup(x => x.CheckCompatibilityAsync(It.IsAny())) .ThrowsAsync(new InvalidOperationException("credentials missing")); var service = CreateService(); @@ -383,7 +344,7 @@ public async Task ReportCompatibility_NoShapes_Skipped() var result = await service.RunAsync(StoreId, request); Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.ReportCompatibility).Status); - _reportClientMock.Verify(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny()), Times.Never); + _reportClientMock.Verify(x => x.CheckCompatibilityAsync(It.IsAny()), Times.Never); } [Fact] @@ -391,8 +352,8 @@ public async Task Realtime_CustomDimensionsRejected_RetriesEventNameOnly() { SetupHappyGooglePath(); _reportClientMock - .Setup(x => x.RunRealtimeReportAsync(It.IsAny(), It.Is(r => r.Dimensions.Count > 1))) - .Callback((string _, RunRealtimeReportRequest request) => _capturedRealtimeRequests.Add(request)) + .Setup(x => x.RunRealtimeReportAsync(It.Is(r => r.Dimensions.Count > 1))) + .Callback((RunRealtimeReportRequest request) => _capturedRealtimeRequests.Add(request)) .ThrowsAsync(CreateRpcException(StatusCode.InvalidArgument, "Field customUser:session_kind is not a valid dimension.")); var service = CreateService(); @@ -437,8 +398,8 @@ public async Task LiveData_Disabled_SkipsBothChecks() AssertStageOrder(result); Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.Realtime).Status); Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.ProcessedData).Status); - _reportClientMock.Verify(x => x.RunRealtimeReportAsync(It.IsAny(), It.IsAny()), Times.Never); - _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny(), It.IsAny()), Times.Never); + _reportClientMock.Verify(x => x.RunRealtimeReportAsync(It.IsAny()), Times.Never); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny()), Times.Never); } [Fact] @@ -446,7 +407,7 @@ public async Task Realtime_HardFailure_FailsRealtimeOnlyProcessedStillRuns() { SetupHappyGooglePath(); _reportClientMock - .Setup(x => x.RunRealtimeReportAsync(It.IsAny(), It.IsAny())) + .Setup(x => x.RunRealtimeReportAsync(It.IsAny())) .ThrowsAsync(CreateRpcException(StatusCode.Unavailable, "try again later")); var service = CreateService(); @@ -476,7 +437,7 @@ private async Task RunApiAccessFailureAsync(RpcExcept { SetupGoogleSettings(); _reportClientMock - .Setup(x => x.GetMetadataAsync(It.IsAny(), It.IsAny())) + .Setup(x => x.GetMetadataAsync(It.IsAny())) .ThrowsAsync(exception); var service = CreateService(); @@ -505,7 +466,7 @@ private void SetupHappyGooglePath() private void SetupGoogleSettings() { - SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId, CredentialJson = """{"type":"service_account"}""" }); + SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId }); } private void SetupSettings(AnalyticsDataApiSettings settings) @@ -524,31 +485,31 @@ private void SetupMetadata(params string[] customDimensionApiNames) } _reportClientMock - .Setup(x => x.GetMetadataAsync(It.IsAny(), PropertyId)) + .Setup(x => x.GetMetadataAsync(PropertyId)) .ReturnsAsync(metadata); } private void SetupCompatibility(CheckCompatibilityResponse response) { _reportClientMock - .Setup(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny())) - .Callback((string _, CheckCompatibilityRequest request) => _capturedCompatibilityRequests.Add(request)) + .Setup(x => x.CheckCompatibilityAsync(It.IsAny())) + .Callback((CheckCompatibilityRequest request) => _capturedCompatibilityRequests.Add(request)) .ReturnsAsync(response); } private void SetupRealtime(RunRealtimeReportResponse response) { _reportClientMock - .Setup(x => x.RunRealtimeReportAsync(It.IsAny(), It.IsAny())) - .Callback((string _, RunRealtimeReportRequest request) => _capturedRealtimeRequests.Add(request)) + .Setup(x => x.RunRealtimeReportAsync(It.IsAny())) + .Callback((RunRealtimeReportRequest request) => _capturedRealtimeRequests.Add(request)) .ReturnsAsync(response); } private void SetupReport(RunReportResponse response) { _reportClientMock - .Setup(x => x.RunReportAsync(It.IsAny(), It.IsAny())) - .Callback((string _, RunReportRequest request) => _capturedReportRequests.Add(request)) + .Setup(x => x.RunReportAsync(It.IsAny())) + .Callback((RunReportRequest request) => _capturedReportRequests.Add(request)) .ReturnsAsync(response); } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index c4003b0..35ac3d4 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -32,17 +32,12 @@ public AnalyticsServiceTests() } [Theory] - [InlineData("123456", null, true)] - [InlineData("123456", "{}", true)] - [InlineData(null, "{}", false)] - [InlineData(null, null, false)] - public async Task IsConfiguredAsync_Matrix(string propertyId, string credentialJson, bool expected) + [InlineData("123456", true)] + [InlineData("", false)] + [InlineData(null, false)] + public async Task IsConfiguredAsync_Matrix(string propertyId, bool expected) { - var service = CreateService(new AnalyticsDataApiSettings - { - PropertyId = propertyId, - CredentialJson = credentialJson, - }); + var service = CreateService(new AnalyticsDataApiSettings { PropertyId = propertyId }); Assert.Equal(expected, await service.IsConfiguredAsync(StoreId)); } @@ -136,7 +131,6 @@ public async Task SearchEventsAsync_PropagatesCriteriaToQuery() Assert.Equal(ModuleConstants.SortBy.Count, capturedQuery.SortBy); Assert.Equal(criteria.DimensionNames, capturedQuery.DimensionNames); Assert.Equal("123456", capturedQuery.PropertyId); - Assert.Equal("{}", capturedQuery.CredentialJson); Assert.Equal(20, capturedQuery.Take); Assert.Equal(5, capturedQuery.Skip); } @@ -258,7 +252,7 @@ private AnalyticsService CreateService(AnalyticsDataApiSettings settings = null, private AnalyticsService CreateGoogleConfiguredService(TimeSpan? failureCacheTtl = null) { - return CreateService(new AnalyticsDataApiSettings { PropertyId = "123456", CredentialJson = "{}" }, failureCacheTtl); + return CreateService(new AnalyticsDataApiSettings { PropertyId = "123456" }, failureCacheTtl); } private static AnalyticsEventSearchCriteria CreateSearchCriteria() diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs index c34c03b..6810a17 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs @@ -30,14 +30,12 @@ public async Task ResolveAsync_MapsStoreSettings() { SetupStore(CreateStore( (ModuleConstants.Settings.DataApi.PropertyId.Name, "123456"), - (ModuleConstants.Settings.DataApi.ServiceAccountJson.Name, "{}"), (ModuleConstants.Settings.DataApi.CacheTtlMinutes.Name, 15))); var resolver = CreateResolver(); var settings = await resolver.ResolveAsync(StoreId); Assert.Equal("123456", settings.PropertyId); - Assert.Equal("{}", settings.CredentialJson); Assert.Equal(15, settings.CacheTtlMinutes); Assert.True(settings.IsConfigured); } @@ -87,7 +85,6 @@ public async Task ResolveAsync_NothingConfigured_ReturnsUnconfiguredSettings() var settings = await resolver.ResolveAsync(StoreId); Assert.Null(settings.PropertyId); - Assert.Null(settings.CredentialJson); Assert.False(settings.IsConfigured); } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs index 9af4c01..8865e8e 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs @@ -24,7 +24,6 @@ public class GoogleAnalyticsDataSourceTests private readonly Mock _reportClientMock = new(); - private string _capturedCredential; private RunReportRequest _capturedRequest; [Fact] @@ -34,7 +33,6 @@ public async Task GetRowsAsync_BuildsRunReportRequest() var query = new AnalyticsDataQuery { PropertyId = "123456", - CredentialJson = "{}", EventNames = new List { ModuleConstants.EventNames.Search }, DimensionNames = new List { ModuleConstants.Dimensions.SearchTerm, ModuleConstants.UserDimensions.OrganizationId }, DimensionFilters = new List @@ -50,7 +48,6 @@ public async Task GetRowsAsync_BuildsRunReportRequest() await dataSource.GetRowsAsync(query); - Assert.Equal("{}", _capturedCredential); Assert.Equal("properties/123456", _capturedRequest.Property); Assert.Equal(10L, _capturedRequest.Limit); Assert.Equal(5L, _capturedRequest.Offset); @@ -324,12 +321,8 @@ public async Task GetRowsAsync_ItemRows_MultipleEventNames_LeavesEventNameNull() private GoogleAnalyticsDataSource CreateDataSource(RunReportResponse response = null) { _reportClientMock - .Setup(x => x.RunReportAsync(It.IsAny(), It.IsAny())) - .Callback((string credential, RunReportRequest request) => - { - _capturedCredential = credential; - _capturedRequest = request; - }) + .Setup(x => x.RunReportAsync(It.IsAny())) + .Callback((RunReportRequest request) => _capturedRequest = request) .ReturnsAsync(response ?? new RunReportResponse()); return new GoogleAnalyticsDataSource(_reportClientMock.Object); diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs index eb20475..11fa9b4 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs @@ -1,7 +1,6 @@ using System.Security.Cryptography; using System.Threading.Tasks; using Google.Apis.Auth.OAuth2; -using Newtonsoft.Json; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; using Xunit; @@ -10,90 +9,66 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; public class GoogleAnalyticsReportClientTests { private const string AnalyticsReadOnlyScope = "https://www.googleapis.com/auth/analytics.readonly"; - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task ResolveCredentialAsync_NoJson_UsesApplicationDefaultCredentials(string credentialJson) - { - var client = new TestableReportClient(); - - var credential = await client.ResolveAsync(credentialJson); - - Assert.Equal(1, client.AdcCallCount); - Assert.IsType(credential.UnderlyingCredential); - Assert.Contains(AnalyticsReadOnlyScope, ((ServiceAccountCredential)credential.UnderlyingCredential).Scopes); - } + private const int PrivateKeySizeInBits = 2048; [Fact] - public async Task ResolveCredentialAsync_ServiceAccountJson_CreatesScopedServiceAccountCredential() + public async Task ResolveCredentialAsync_UsesApplicationDefaultCredentialsScopedForAnalytics() { - var client = new TestableReportClient(); + var client = new TestableReportClient(CreateUnscopedCredential()); - var credential = await client.ResolveAsync(CreateServiceAccountJson()); + var credential = await client.ResolveAsync(); - Assert.Equal(0, client.AdcCallCount); + Assert.Equal(1, client.AdcCallCount); var serviceAccountCredential = Assert.IsType(credential.UnderlyingCredential); Assert.Contains(AnalyticsReadOnlyScope, serviceAccountCredential.Scopes); - Assert.False(credential.IsCreateScopedRequired); } [Fact] - public async Task ResolveCredentialAsync_ExternalAccountJson_CreatesWorkloadIdentityFederationCredential() + public async Task ResolveCredentialAsync_AlreadyScopedCredential_ReturnsItUnchanged() { - var client = new TestableReportClient(); + var scopedCredential = CreateUnscopedCredential().CreateScoped(AnalyticsReadOnlyScope); + var client = new TestableReportClient(scopedCredential); - var credential = await client.ResolveAsync(CreateExternalAccountJson()); + var credential = await client.ResolveAsync(); - Assert.Equal(0, client.AdcCallCount); - Assert.NotNull(credential.UnderlyingCredential); - Assert.Contains("ExternalAccountCredential", credential.UnderlyingCredential.GetType().Name); + Assert.Equal(1, client.AdcCallCount); + Assert.Same(scopedCredential, credential); Assert.False(credential.IsCreateScopedRequired); } - private static string CreateServiceAccountJson() + private static GoogleCredential CreateUnscopedCredential() { - using var rsa = RSA.Create(2048); + using var rsa = RSA.Create(PrivateKeySizeInBits); var privateKey = PemEncoding.WriteString("PRIVATE KEY", rsa.ExportPkcs8PrivateKey()); - return JsonConvert.SerializeObject(new + var initializer = new ServiceAccountCredential.Initializer("probe@test-project.iam.gserviceaccount.com") { - type = "service_account", - project_id = "test-project", - private_key_id = "key1", - private_key = privateKey, - client_email = "probe@test-project.iam.gserviceaccount.com", - client_id = "1", - token_uri = "https://oauth2.googleapis.com/token", - }); - } + ProjectId = "test-project", + }.FromPrivateKey(privateKey); - private static string CreateExternalAccountJson() - { - return JsonConvert.SerializeObject(new - { - type = "external_account", - audience = "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/azure", - subject_token_type = "urn:ietf:params:oauth:token-type:jwt", - token_url = "https://sts.googleapis.com/v1/token", - credential_source = new { url = "https://login.example.com/token" }, - }); + return GoogleCredential.FromServiceAccountCredential(new ServiceAccountCredential(initializer)); } private sealed class TestableReportClient : GoogleAnalyticsReportClient { + private readonly GoogleCredential _applicationDefaultCredential; + + public TestableReportClient(GoogleCredential applicationDefaultCredential) + { + _applicationDefaultCredential = applicationDefaultCredential; + } + public int AdcCallCount { get; private set; } - public Task ResolveAsync(string credentialJson) + public Task ResolveAsync() { - return ResolveCredentialAsync(credentialJson); + return ResolveCredentialAsync(); } protected override Task GetApplicationDefaultCredentialAsync() { AdcCallCount++; - return Task.FromResult(CredentialFactory.FromJson(CreateServiceAccountJson(), JsonCredentialParameters.ServiceAccountCredentialType)); + return Task.FromResult(_applicationDefaultCredential); } } } From 925908071eaecdcbd49bc760ce6cde917b936560 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 27 Aug 2026 14:33:01 -0500 Subject: [PATCH 07/36] CI trigger --- .../ModuleConstants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index d2a3313..a0d158c 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using VirtoCommerce.Platform.Core.Settings; namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core { From 47ba6ff1cb76d76f3df4f99d398faca00c43bacc Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 27 Aug 2026 14:33:12 -0500 Subject: [PATCH 08/36] CI trigger --- .../ModuleConstants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index a0d158c..d2a3313 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using VirtoCommerce.Platform.Core.Settings; namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core { From e1b654d6fe5fac2c8cd61a870ae42800cefb54e1 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 27 Aug 2026 15:08:39 -0500 Subject: [PATCH 09/36] Localizations --- .../de.VirtoCommerce.GoogleEcommerceAnalytics.json | 10 ++++++++++ .../en.VirtoCommerce.GoogleEcommerceAnalytics.json | 10 ++++++++++ .../es.VirtoCommerce.GoogleEcommerceAnalytics.json | 10 ++++++++++ .../fr.VirtoCommerce.GoogleEcommerceAnalytics.json | 10 ++++++++++ .../it.VirtoCommerce.GoogleEcommerceAnalytics.json | 10 ++++++++++ .../ja.VirtoCommerce.GoogleEcommerceAnalytics.json | 10 ++++++++++ .../pl.VirtoCommerce.GoogleEcommerceAnalytics.json | 10 ++++++++++ .../pt.VirtoCommerce.GoogleEcommerceAnalytics.json | 10 ++++++++++ .../ru.VirtoCommerce.GoogleEcommerceAnalytics.json | 10 ++++++++++ .../zh.VirtoCommerce.GoogleEcommerceAnalytics.json | 10 ++++++++++ 10 files changed, 100 insertions(+) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/de.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/de.VirtoCommerce.GoogleEcommerceAnalytics.json index 6da0bd1..12f43a0 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/de.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/de.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -16,6 +16,16 @@ "GoogleAnalyticsUrl": { "title": "Google Analytics-Dashboard-URL", "description": "Google Analytics-Dashboard-URL für die Umleitung aus dem App-Menü" + }, + "DataApi": { + "PropertyId": { + "title": "GA4-Property-ID", + "description": "Numerische Google Analytics 4 Property-ID zum Lesen von Berichten über die Data API (nicht die Mess-ID G-XXXXXXX)" + }, + "CacheTtlMinutes": { + "title": "Cache-Lebensdauer der Berichte, Minuten", + "description": "Wie lange Berichtsergebnisse von Google Analytics pro Store zwischengespeichert werden" + } } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/en.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/en.VirtoCommerce.GoogleEcommerceAnalytics.json index d4b6c20..6088fbc 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/en.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/en.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -16,6 +16,16 @@ "GoogleAnalyticsUrl": { "title": "Google Analytics dashboard URL", "description": "Provide the URL for the Google Analytics dashboard for redirecting from App Menu" + }, + "DataApi": { + "PropertyId": { + "title": "GA4 property Id", + "description": "Numeric Google Analytics 4 property ID used to read reports through the Data API (not the G-XXXXXXX measurement ID)" + }, + "CacheTtlMinutes": { + "title": "Report cache lifetime, minutes", + "description": "How long Google Analytics report results are cached per store" + } } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/es.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/es.VirtoCommerce.GoogleEcommerceAnalytics.json index b575016..eec4fd6 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/es.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/es.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -16,6 +16,16 @@ "GoogleAnalyticsUrl": { "title": "URL del panel de control de Google Analytics", "description": "URL del panel de control de Google Analytics para redirigir desde el menú de la aplicación" + }, + "DataApi": { + "PropertyId": { + "title": "Id de propiedad GA4", + "description": "ID numérico de la propiedad de Google Analytics 4 para leer informes mediante la Data API (no el ID de medición G-XXXXXXX)" + }, + "CacheTtlMinutes": { + "title": "Duración de la caché de informes, minutos", + "description": "Cuánto tiempo se almacenan en caché los resultados de los informes de Google Analytics por tienda" + } } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/fr.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/fr.VirtoCommerce.GoogleEcommerceAnalytics.json index 4bdc23f..56e1f43 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/fr.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/fr.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -16,6 +16,16 @@ "GoogleAnalyticsUrl": { "title": "URL du tableau de bord Google Analytics", "description": "URL du tableau de bord Google Analytics pour redirection depuis le menu de l'application" + }, + "DataApi": { + "PropertyId": { + "title": "Id de propriété GA4", + "description": "Identifiant numérique de la propriété Google Analytics 4 pour lire les rapports via la Data API (et non l'identifiant de mesure G-XXXXXXX)" + }, + "CacheTtlMinutes": { + "title": "Durée du cache des rapports, minutes", + "description": "Durée de mise en cache des résultats des rapports Google Analytics par magasin" + } } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/it.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/it.VirtoCommerce.GoogleEcommerceAnalytics.json index bef00f2..39bb3c9 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/it.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/it.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -16,6 +16,16 @@ "GoogleAnalyticsUrl": { "title": "URL del dashboard di Google Analytics", "description": "URL del dashboard di Google Analytics per il reindirizzamento dal menu dell'app" + }, + "DataApi": { + "PropertyId": { + "title": "Id proprietà GA4", + "description": "ID numerico della proprietà Google Analytics 4 per leggere i report tramite la Data API (non l'ID di misurazione G-XXXXXXX)" + }, + "CacheTtlMinutes": { + "title": "Durata della cache dei report, minuti", + "description": "Per quanto tempo i risultati dei report di Google Analytics vengono memorizzati nella cache per negozio" + } } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ja.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ja.VirtoCommerce.GoogleEcommerceAnalytics.json index 98f25e8..d119bdf 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ja.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ja.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -16,6 +16,16 @@ "GoogleAnalyticsUrl": { "title": "Google Analytics ダッシュボード URL", "description": "アプリ メニューからリダイレクトするための Google Analytics ダッシュボード URL" + }, + "DataApi": { + "PropertyId": { + "title": "GA4 プロパティ ID", + "description": "Data API でレポートを読み取るための Google Analytics 4 の数値プロパティ ID(G-XXXXXXX の測定 ID ではありません)" + }, + "CacheTtlMinutes": { + "title": "レポートキャッシュの有効期間(分)", + "description": "ストアごとに Google Analytics のレポート結果をキャッシュする時間" + } } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pl.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pl.VirtoCommerce.GoogleEcommerceAnalytics.json index 10cf13d..c18f592 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pl.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pl.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -16,6 +16,16 @@ "GoogleAnalyticsUrl": { "title": "URL panelu Google Analytics", "description": "URL panelu Google Analytics do przekierowania z menu aplikacji" + }, + "DataApi": { + "PropertyId": { + "title": "Identyfikator usługi GA4", + "description": "Numeryczny identyfikator usługi Google Analytics 4 do odczytu raportów przez Data API (nie identyfikator pomiaru G-XXXXXXX)" + }, + "CacheTtlMinutes": { + "title": "Czas życia pamięci podręcznej raportów, minuty", + "description": "Jak długo wyniki raportów Google Analytics są przechowywane w pamięci podręcznej dla sklepu" + } } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pt.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pt.VirtoCommerce.GoogleEcommerceAnalytics.json index 6a744af..2ad7b99 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pt.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pt.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -16,6 +16,16 @@ "GoogleAnalyticsUrl": { "title": "URL do painel do Google Analytics", "description": "URL do painel do Google Analytics para redirecionamento do Menu do Aplicativo" + }, + "DataApi": { + "PropertyId": { + "title": "Id da propriedade GA4", + "description": "ID numérico da propriedade do Google Analytics 4 para ler relatórios pela Data API (não o ID de medição G-XXXXXXX)" + }, + "CacheTtlMinutes": { + "title": "Tempo de cache dos relatórios, minutos", + "description": "Por quanto tempo os resultados dos relatórios do Google Analytics são armazenados em cache por loja" + } } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ru.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ru.VirtoCommerce.GoogleEcommerceAnalytics.json index 221d31f..e92b84b 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ru.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ru.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -16,6 +16,16 @@ "GoogleAnalyticsUrl": { "title": "URL панели управления Google Analytics", "description": "URL панели управления Google Analytics для перенаправления из меню приложения" + }, + "DataApi": { + "PropertyId": { + "title": "Идентификатор ресурса GA4", + "description": "Числовой идентификатор ресурса Google Analytics 4 для чтения отчётов через Data API (не идентификатор измерений G-XXXXXXX)" + }, + "CacheTtlMinutes": { + "title": "Время жизни кэша отчётов, минуты", + "description": "Как долго результаты отчётов Google Analytics кэшируются для магазина" + } } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/zh.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/zh.VirtoCommerce.GoogleEcommerceAnalytics.json index a31802f..2527769 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/zh.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/zh.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -16,6 +16,16 @@ "GoogleAnalyticsUrl": { "title": "Google Analytics 仪表板 URL", "description": "从应用菜单重定向的 Google Analytics 仪表板 URL" + }, + "DataApi": { + "PropertyId": { + "title": "GA4 媒体资源 ID", + "description": "用于通过 Data API 读取报告的 Google Analytics 4 数值媒体资源 ID(不是 G-XXXXXXX 衡量 ID)" + }, + "CacheTtlMinutes": { + "title": "报告缓存有效期(分钟)", + "description": "每个商店的 Google Analytics 报告结果缓存时长" + } } } } From 511b0642bae2f2bcfb69415986a5597b9ce36e95 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Fri, 28 Aug 2026 07:48:22 -0500 Subject: [PATCH 10/36] Refactoring (/vc-module-review) --- .../Services/AnalyticsService.cs | 17 +++++++++-------- .../Services/AnalyticsSettingsResolver.cs | 11 ++++++----- ...e.GoogleEcommerceAnalyticsModule.Data.csproj | 1 + .../Module.cs | 3 +++ 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 9bd6188..4751a11 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -144,14 +144,15 @@ protected virtual IAnalyticsDataSource ResolveDataSource(AnalyticsDataApiSetting protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settings, AnalyticsEventCriteriaBase criteria) { - return new AnalyticsDataQuery - { - PropertyId = settings.PropertyId, - EventNames = criteria.EventNames, - DimensionFilters = criteria.DimensionFilters, - From = criteria.From, - To = criteria.To, - }; + var query = AbstractTypeFactory.TryCreateInstance(); + + query.PropertyId = settings.PropertyId; + query.EventNames = criteria.EventNames; + query.DimensionFilters = criteria.DimensionFilters; + query.From = criteria.From; + query.To = criteria.To; + + return query; } protected virtual IList CreateSummaries(AnalyticsEventSummaryCriteria criteria, IList events) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs index db91379..e075db4 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs @@ -25,11 +25,12 @@ public virtual async Task ResolveAsync(string storeId) var store = string.IsNullOrEmpty(storeId) ? null : await _storeService.GetNoCloneAsync(storeId); var storeSettings = store?.Settings; - return new AnalyticsDataApiSettings - { - PropertyId = await GetSettingAsync(storeSettings, DataApiSettings.PropertyId), - CacheTtlMinutes = await GetSettingAsync(storeSettings, DataApiSettings.CacheTtlMinutes), - }; + var result = AbstractTypeFactory.TryCreateInstance(); + + result.PropertyId = await GetSettingAsync(storeSettings, DataApiSettings.PropertyId); + result.CacheTtlMinutes = await GetSettingAsync(storeSettings, DataApiSettings.CacheTtlMinutes); + + return result; } protected virtual async Task GetSettingAsync(ICollection storeSettings, SettingDescriptor descriptor) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj index f15e907..ff64d6e 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj @@ -13,6 +13,7 @@ + diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs index 4af5446..c3575a5 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs @@ -4,6 +4,7 @@ using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; using VirtoCommerce.Platform.Core.Common; using VirtoCommerce.Platform.Core.Modularity; @@ -26,6 +27,8 @@ public void Initialize(IServiceCollection serviceCollection) serviceCollection.AddTransient(); serviceCollection.AddTransient(); + AbstractTypeFactory.RegisterType(); + AbstractTypeFactory.RegisterType(); AbstractTypeFactory.RegisterType(); AbstractTypeFactory.RegisterType(); AbstractTypeFactory.RegisterType(); From cc81ac599b2710caecf62ef387d8d509bed2c81c Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Fri, 28 Aug 2026 10:10:33 -0500 Subject: [PATCH 11/36] Refactoring (/simplify) --- .../Models/AnalyticsDimensionFilter.cs | 3 +- .../Models/AnalyticsEventCriteriaBase.cs | 3 +- .../Models/AnalyticsEventSearchCriteria.cs | 4 - .../ModuleConstants.cs | 10 ++ .../Services/AnalyticsDiagnosticsService.cs | 89 +++-------- .../Services/AnalyticsFilterBuilder.cs | 50 ++++++ .../Services/AnalyticsService.cs | 142 +++++++++--------- .../Services/AnalyticsSettingsResolver.cs | 10 +- .../Services/GoogleAnalyticsDataSource.cs | 64 +++----- .../Services/GoogleAnalyticsReportClient.cs | 56 ++++++- .../Services/IAnalyticsDataSource.cs | 10 -- .../AnalyticsDiagnosticsServiceTests.cs | 15 +- .../AnalyticsServiceTests.cs | 28 +++- 13 files changed, 253 insertions(+), 231 deletions(-) create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs delete mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs index 6cd0778..b4b51d1 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs @@ -1,8 +1,9 @@ using System.Collections.Generic; +using VirtoCommerce.Platform.Core.Common; namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; -public class AnalyticsDimensionFilter +public class AnalyticsDimensionFilter : ValueObject { public string DimensionName { get; set; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs index 6539b82..4312177 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs @@ -1,9 +1,10 @@ using System; using System.Collections.Generic; +using VirtoCommerce.Platform.Core.Common; namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; -public abstract class AnalyticsEventCriteriaBase +public abstract class AnalyticsEventCriteriaBase : SearchCriteriaBase { public string StoreId { get; set; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs index d1cabe1..846ecd3 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs @@ -7,8 +7,4 @@ public class AnalyticsEventSearchCriteria : AnalyticsEventCriteriaBase public IList DimensionNames { get; set; } = new List(); public string SortBy { get; set; } = ModuleConstants.SortBy.Date; - - public int Take { get; set; } = 20; - - public int Skip { get; set; } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index d2a3313..6da89e7 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -28,6 +28,8 @@ public static class EventNames public static class UserDimensions { + public const string Prefix = "customUser:"; + public const string ContactId = "contact_id"; public const string OrganizationId = "organization_id"; public const string OrganizationName = "organization_name"; @@ -53,6 +55,12 @@ public static class Dimensions public const string DateHour = "dateHour"; } + public static class Metrics + { + public const string EventCount = "eventCount"; + public const string ItemsViewed = "itemsViewed"; + } + public static class SortBy { public const string Date = "date"; @@ -68,6 +76,8 @@ public static class DiagnosticsStages public const string ReportCompatibility = "reportCompatibility"; public const string Realtime = "realtime"; public const string ProcessedData = "processedData"; + + public static string[] AllStages { get; } = { Configuration, Credentials, ApiAccess, CustomDimensions, ReportCompatibility, Realtime, ProcessedData }; } public static class DiagnosticsStatuses diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs index a1c05f1..68e5671 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -20,8 +20,6 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; public class AnalyticsDiagnosticsService : IAnalyticsDiagnosticsService { - private const string UserDimensionPrefix = "customUser:"; - private const string EventCountMetric = "eventCount"; private const string ApplicationDefaultCredentials = "Application Default Credentials"; private const string ProbeFilterValue = "diagnostics-probe"; private const string ProcessedDataStartDate = "7daysAgo"; @@ -35,17 +33,6 @@ public class AnalyticsDiagnosticsService : IAnalyticsDiagnosticsService private const string ConsumerMetadataKey = "consumer"; private const string ProjectResourcePrefix = "projects/"; - private static readonly string[] AllStages = - { - Stages.Configuration, - Stages.Credentials, - Stages.ApiAccess, - Stages.CustomDimensions, - Stages.ReportCompatibility, - Stages.Realtime, - Stages.ProcessedData, - }; - private readonly IAnalyticsSettingsResolver _settingsResolver; private readonly IGoogleAnalyticsReportClient _reportClient; @@ -111,7 +98,7 @@ protected virtual async Task CheckConfigurationAsync(s } AddCheck(checks, Stages.Configuration, Statuses.Passed, - $"Property {settings.PropertyId} configured; credential: {DescribeCredentialSource()}."); + $"Property {settings.PropertyId} configured; credential: {ApplicationDefaultCredentials}."); return settings; } @@ -210,7 +197,9 @@ protected virtual void CheckCustomDimensions(Metadata metadata, AnalyticsDiagnos .Select(x => x.ApiName) .Where(x => !string.IsNullOrEmpty(x)) .ToHashSet(StringComparer.OrdinalIgnoreCase); - var missingNames = requestedNames.Where(x => !registeredNames.Contains(UserDimensionPrefix + x)).ToList(); + var missingNames = requestedNames + .Where(x => !registeredNames.Contains(ModuleConstants.UserDimensions.Prefix + x)) + .ToList(); if (missingNames.Count == 0) { @@ -290,21 +279,21 @@ protected virtual async Task CheckRealtimeAsync(AnalyticsDataApiSettings setting try { RunRealtimeReportResponse response; - var checkedCustomDimensions = userDimensionNames.Count > 0; + var usedFallback = false; try { response = await _reportClient.RunRealtimeReportAsync( BuildRealtimeRequest(settings.PropertyId, userDimensionNames)); } - catch (RpcException ex) when (checkedCustomDimensions && ex.StatusCode == StatusCode.InvalidArgument) + catch (RpcException ex) when (userDimensionNames.Count > 0 && ex.StatusCode == StatusCode.InvalidArgument) { - checkedCustomDimensions = false; + usedFallback = true; response = await _reportClient.RunRealtimeReportAsync( - BuildRealtimeRequest(settings.PropertyId, new List())); + BuildRealtimeRequest(settings.PropertyId, [])); } - var fallbackNote = !checkedCustomDimensions && userDimensionNames.Count > 0 + var fallbackNote = usedFallback ? " Realtime does not support the custom dimensions on this property — checked event stream only." : string.Empty; var eventCounts = AggregateEventCounts(response.DimensionHeaders, response.Rows); @@ -353,7 +342,7 @@ protected virtual CheckCompatibilityRequest BuildCompatibilityRequest(string pro { var request = new CheckCompatibilityRequest { - Property = $"properties/{propertyId}", + Property = AnalyticsFilterBuilder.PropertyName(propertyId), CompatibilityFilter = Compatibility.Incompatible, }; @@ -371,13 +360,13 @@ protected virtual CheckCompatibilityRequest BuildCompatibilityRequest(string pro var eventNames = (shape.EventNames ?? []).Where(x => !string.IsNullOrEmpty(x)).ToList(); if (eventNames.Count > 0) { - expressions.Add(CreateInListExpression(ModuleConstants.Dimensions.EventName, eventNames)); + expressions.Add(AnalyticsFilterBuilder.CreateInListExpression(ModuleConstants.Dimensions.EventName, eventNames)); } expressions.AddRange(userDimensionNames - .Select(x => CreateInListExpression(UserDimensionPrefix + x, new[] { ProbeFilterValue }))); + .Select(x => AnalyticsFilterBuilder.CreateInListExpression(ModuleConstants.UserDimensions.Prefix + x, new[] { ProbeFilterValue }))); - var dimensionFilter = CombineExpressions(expressions); + var dimensionFilter = AnalyticsFilterBuilder.Combine(expressions); if (dimensionFilter != null) { request.DimensionFilter = dimensionFilter; @@ -390,17 +379,17 @@ protected virtual RunRealtimeReportRequest BuildRealtimeRequest(string propertyI { var request = new RunRealtimeReportRequest { - Property = $"properties/{propertyId}", + Property = AnalyticsFilterBuilder.PropertyName(propertyId), Limit = LiveDataRowsLimit, }; request.Dimensions.Add(new Dimension { Name = ModuleConstants.Dimensions.EventName }); foreach (var dimensionName in userDimensionNames) { - request.Dimensions.Add(new Dimension { Name = UserDimensionPrefix + dimensionName }); + request.Dimensions.Add(new Dimension { Name = ModuleConstants.UserDimensions.Prefix + dimensionName }); } - request.Metrics.Add(new Metric { Name = EventCountMetric }); + request.Metrics.Add(new Metric { Name = ModuleConstants.Metrics.EventCount }); request.MinuteRanges.Add(new MinuteRange { StartMinutesAgo = RealtimeMinutesAgo, EndMinutesAgo = 0 }); return request; @@ -410,13 +399,13 @@ protected virtual RunReportRequest BuildProcessedDataRequest(string propertyId) { var request = new RunReportRequest { - Property = $"properties/{propertyId}", + Property = AnalyticsFilterBuilder.PropertyName(propertyId), Limit = LiveDataRowsLimit, }; request.DateRanges.Add(new DateRange { StartDate = ProcessedDataStartDate, EndDate = ProcessedDataEndDate }); request.Dimensions.Add(new Dimension { Name = ModuleConstants.Dimensions.EventName }); - request.Metrics.Add(new Metric { Name = EventCountMetric }); + request.Metrics.Add(new Metric { Name = ModuleConstants.Metrics.EventCount }); return request; } @@ -511,11 +500,6 @@ protected virtual string DescribeMissingRequestedEvents(AnalyticsDiagnosticsRequ return missingNames.Count > 0 ? $" Requested events not seen: {string.Join(", ", missingNames)}." : string.Empty; } - protected virtual string DescribeCredentialSource() - { - return ApplicationDefaultCredentials; - } - protected virtual string DescribeError(Exception exception) { return exception is RpcException rpcException @@ -533,20 +517,18 @@ protected virtual IList GetUserDimensionNames(AnalyticsDiagnosticsReques protected virtual string MapDimensionName(string dimensionName, IList userDimensionNames) { - return userDimensionNames.Contains(dimensionName) || ModuleConstants.UserDimensions.AllNames.Contains(dimensionName) - ? UserDimensionPrefix + dimensionName - : dimensionName; + return AnalyticsFilterBuilder.MapDimensionName(dimensionName, userDimensionNames); } protected virtual void SkipRemainingStages(IList checks, string message) { - foreach (var stage in AllStages.Skip(checks.Count)) + foreach (var stage in Stages.AllStages.Except(checks.Select(x => x.Stage))) { AddCheck(checks, stage, Statuses.Skipped, message); } } - protected virtual AnalyticsDiagnosticsCheck AddCheck(IList checks, string stage, string status, string message, string detail = null) + protected virtual void AddCheck(IList checks, string stage, string status, string message, string detail = null) { var check = AbstractTypeFactory.TryCreateInstance(); check.Stage = stage; @@ -554,7 +536,6 @@ protected virtual AnalyticsDiagnosticsCheck AddCheck(IList expressions) - { - if (expressions.Count == 0) - { - return null; - } - - if (expressions.Count == 1) - { - return expressions[0]; - } - - var result = new FilterExpression { AndGroup = new FilterExpressionList() }; - result.AndGroup.Expressions.AddRange(expressions); - return result; - } - - private static FilterExpression CreateInListExpression(string fieldName, IEnumerable values) - { - var inListFilter = new Filter.Types.InListFilter(); - inListFilter.Values.AddRange(values); - - return new FilterExpression - { - Filter = new Filter { FieldName = fieldName, InListFilter = inListFilter }, - }; - } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs new file mode 100644 index 0000000..ddb585e --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Linq; +using Google.Analytics.Data.V1Beta; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.Platform.Core.Common; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +internal static class AnalyticsFilterBuilder +{ + public static string PropertyName(string propertyId) + { + return $"properties/{propertyId}"; + } + + public static string MapDimensionName(string dimensionName, IList extraUserDimensionNames = null) + { + return ModuleConstants.UserDimensions.AllNames.Contains(dimensionName) || extraUserDimensionNames?.Contains(dimensionName) == true + ? ModuleConstants.UserDimensions.Prefix + dimensionName + : dimensionName; + } + + public static FilterExpression CreateInListExpression(string fieldName, IEnumerable values) + { + var inListFilter = new Filter.Types.InListFilter(); + inListFilter.Values.AddRange(values); + + return new FilterExpression + { + Filter = new Filter { FieldName = fieldName, InListFilter = inListFilter }, + }; + } + + public static FilterExpression Combine(IList expressions) + { + if (expressions.IsNullOrEmpty()) + { + return null; + } + + if (expressions.Count == 1) + { + return expressions[0]; + } + + var result = new FilterExpression { AndGroup = new FilterExpressionList() }; + result.AndGroup.Expressions.AddRange(expressions); + return result; + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 4751a11..0667a27 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -3,7 +3,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; @@ -15,6 +14,8 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; public class AnalyticsService : IAnalyticsService { private const int SummaryRowsLimit = 100_000; + private const string SearchOperation = "events search"; + private const string SummariesOperation = "event summaries"; private readonly IAnalyticsSettingsResolver _settingsResolver; private readonly IPlatformMemoryCache _platformMemoryCache; @@ -53,95 +54,83 @@ public virtual async Task SearchEventsAsync(Analytic { ArgumentNullException.ThrowIfNull(criteria); - try - { - var settings = await _settingsResolver.ResolveAsync(criteria.StoreId); - var dataSource = ResolveDataSource(settings); - if (dataSource == null) + var result = await GetOrCreateAsync( + SearchOperation, + criteria, + settings => { - return AbstractTypeFactory.TryCreateInstance(); - } + var query = CreateQuery(settings, criteria); + query.DimensionNames = criteria.DimensionNames; + query.SortBy = criteria.SortBy; + query.Take = criteria.Take; + query.Skip = criteria.Skip; - var cacheKey = CacheKey.With(GetType(), nameof(SearchEventsAsync), GetCriteriaCacheKey(criteria)); - var result = await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, async cacheOptions => - { - cacheOptions.AbsoluteExpirationRelativeToNow = GetCacheTtl(settings); + return _googleAnalyticsDataSource.GetRowsAsync(query); + }, + AbstractTypeFactory.TryCreateInstance); - try - { - var query = CreateQuery(settings, criteria); - query.DimensionNames = criteria.DimensionNames; - query.SortBy = criteria.SortBy; - query.Take = criteria.Take; - query.Skip = criteria.Skip; - - return await dataSource.GetRowsAsync(query); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Google Analytics events search failed for store {StoreId}", criteria.StoreId); - cacheOptions.AbsoluteExpirationRelativeToNow = FailureCacheTtl; - return AbstractTypeFactory.TryCreateInstance(); - } - }); - - return result?.CloneTyped() ?? AbstractTypeFactory.TryCreateInstance(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Google Analytics events search failed for store {StoreId}", criteria.StoreId); - return AbstractTypeFactory.TryCreateInstance(); - } + return result.CloneTyped(); } public virtual async Task> GetEventSummariesAsync(AnalyticsEventSummaryCriteria criteria) { ArgumentNullException.ThrowIfNull(criteria); + var result = await GetOrCreateAsync( + SummariesOperation, + criteria, + async settings => + { + var query = CreateQuery(settings, criteria); + query.Take = SummaryRowsLimit; + + var rows = await _googleAnalyticsDataSource.GetRowsAsync(query); + return CreateSummaries(criteria, rows.Events); + }, + () => CreateEmptySummaries(criteria)); + + return result.Select(x => x.CloneTyped()).ToList(); + } + + protected virtual async Task GetOrCreateAsync( + string operation, + AnalyticsEventCriteriaBase criteria, + Func> factory, + Func createEmptyResult) + { try { var settings = await _settingsResolver.ResolveAsync(criteria.StoreId); - var dataSource = ResolveDataSource(settings); - if (dataSource == null) + if (!settings.IsConfigured) { - return CreateSummaries(criteria, new List()); + return createEmptyResult(); } - var cacheKey = CacheKey.With(GetType(), nameof(GetEventSummariesAsync), GetCriteriaCacheKey(criteria)); - var result = await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, async cacheOptions => + var cacheKey = CacheKey.With(GetType(), operation, criteria.GetCacheKey()); + + return await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, async cacheOptions => { cacheOptions.AbsoluteExpirationRelativeToNow = GetCacheTtl(settings); try { - var query = CreateQuery(settings, criteria); - query.Take = SummaryRowsLimit; - - var rows = await dataSource.GetRowsAsync(query); - return CreateSummaries(criteria, rows.Events); + return await factory(settings); } catch (Exception ex) { - _logger.LogWarning(ex, "Google Analytics event summaries failed for store {StoreId}", criteria.StoreId); + _logger.LogWarning(ex, "Google Analytics {Operation} failed for store {StoreId}", operation, criteria.StoreId); cacheOptions.AbsoluteExpirationRelativeToNow = FailureCacheTtl; - return CreateSummaries(criteria, new List()); + return createEmptyResult(); } }); - - return result?.Select(x => x.CloneTyped()).ToList() ?? CreateSummaries(criteria, new List()); } catch (Exception ex) { - _logger.LogWarning(ex, "Google Analytics event summaries failed for store {StoreId}", criteria.StoreId); - return CreateSummaries(criteria, new List()); + _logger.LogWarning(ex, "Google Analytics {Operation} failed for store {StoreId}", operation, criteria.StoreId); + return createEmptyResult(); } } - protected virtual IAnalyticsDataSource ResolveDataSource(AnalyticsDataApiSettings settings) - { - return settings.IsConfigured ? _googleAnalyticsDataSource : null; - } - protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settings, AnalyticsEventCriteriaBase criteria) { var query = AbstractTypeFactory.TryCreateInstance(); @@ -155,14 +144,28 @@ protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settin return query; } + // Not an empty list: requested event names still yield zero-count summaries. + protected virtual IList CreateEmptySummaries(AnalyticsEventSummaryCriteria criteria) + { + return CreateSummaries(criteria, []); + } + protected virtual IList CreateSummaries(AnalyticsEventSummaryCriteria criteria, IList events) { - var groups = events - .Where(x => !string.IsNullOrEmpty(x.EventName)) - .GroupBy(x => x.EventName) - .ToDictionary(x => x.Key, x => x.ToList()); + var aggregates = new Dictionary(); + + foreach (var analyticsEvent in events.Where(x => !string.IsNullOrEmpty(x.EventName))) + { + aggregates.TryGetValue(analyticsEvent.EventName, out var aggregate); - var eventNames = criteria.EventNames?.Count > 0 ? criteria.EventNames : groups.Keys.ToList(); + aggregates[analyticsEvent.EventName] = ( + aggregate.TotalCount + analyticsEvent.Count, + aggregate.LastOccurredAt == null || analyticsEvent.OccurredAt > aggregate.LastOccurredAt + ? analyticsEvent.OccurredAt + : aggregate.LastOccurredAt); + } + + var eventNames = criteria.EventNames.IsNullOrEmpty() ? aggregates.Keys.ToList() : criteria.EventNames; return eventNames .Select(eventName => @@ -170,10 +173,10 @@ protected virtual IList CreateSummaries(AnalyticsEventSum var summary = AbstractTypeFactory.TryCreateInstance(); summary.EventName = eventName; - if (groups.TryGetValue(eventName, out var group)) + if (aggregates.TryGetValue(eventName, out var aggregate)) { - summary.TotalCount = group.Sum(x => x.Count); - summary.LastOccurredAt = group.Max(x => x.OccurredAt); + summary.TotalCount = aggregate.TotalCount; + summary.LastOccurredAt = aggregate.LastOccurredAt; } return summary; @@ -181,12 +184,7 @@ protected virtual IList CreateSummaries(AnalyticsEventSum .ToList(); } - protected virtual string GetCriteriaCacheKey(AnalyticsEventCriteriaBase criteria) - { - return JsonConvert.SerializeObject(criteria); - } - - private static TimeSpan GetCacheTtl(AnalyticsDataApiSettings settings) + protected virtual TimeSpan GetCacheTtl(AnalyticsDataApiSettings settings) { return TimeSpan.FromMinutes(Math.Max(1, settings.CacheTtlMinutes)); } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs index e075db4..b7fe48d 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs @@ -35,12 +35,8 @@ public virtual async Task ResolveAsync(string storeId) protected virtual async Task GetSettingAsync(ICollection storeSettings, SettingDescriptor descriptor) { - var storeEntry = storeSettings?.FirstOrDefault(x => x.Name.EqualsIgnoreCase(descriptor.Name)); - if (storeEntry?.Value != null) - { - return storeSettings.GetValue(descriptor); - } - - return await _settingsManager.GetValueAsync(descriptor); + return storeSettings?.Any(x => x.Name.EqualsIgnoreCase(descriptor.Name) && x.Value != null) == true + ? storeSettings.GetValue(descriptor) + : await _settingsManager.GetValueAsync(descriptor); } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs index e0acbe7..a87dcc9 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs @@ -11,12 +11,9 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; -public class GoogleAnalyticsDataSource : IAnalyticsDataSource +public class GoogleAnalyticsDataSource { private const string NotSetValue = "(not set)"; - private const string UserDimensionPrefix = "customUser:"; - private const string EventCountMetric = "eventCount"; - private const string ItemsViewedMetric = "itemsViewed"; private const string DateHourFormat = "yyyyMMddHH"; private const string DateFormat = "yyyy-MM-dd"; private const string DefaultStartDate = "2015-08-14"; @@ -66,28 +63,28 @@ protected virtual async Task GetItemScopedRowsAsync( return result; } - public virtual RunReportRequest BuildEventReportRequest(AnalyticsDataQuery query) + protected virtual RunReportRequest BuildEventReportRequest(AnalyticsDataQuery query) { var request = CreateRequest(query); request.Dimensions.Add(new Dimension { Name = ModuleConstants.Dimensions.EventName }); AddDateHourAndExtraDimensions(request, query); - request.Metrics.Add(new Metric { Name = EventCountMetric }); - AddOrderBy(request, query, EventCountMetric); + request.Metrics.Add(new Metric { Name = ModuleConstants.Metrics.EventCount }); + AddOrderBy(request, query, ModuleConstants.Metrics.EventCount); return request; } // Item-scoped report shape per the GA4 schema: metric itemsViewed, eventName used only as a dimension filter. - public virtual RunReportRequest BuildItemReportRequest(AnalyticsDataQuery query) + protected virtual RunReportRequest BuildItemReportRequest(AnalyticsDataQuery query) { var request = CreateRequest(query); AddDateHourAndExtraDimensions(request, query); - request.Metrics.Add(new Metric { Name = ItemsViewedMetric }); - AddOrderBy(request, query, ItemsViewedMetric); + request.Metrics.Add(new Metric { Name = ModuleConstants.Metrics.ItemsViewed }); + AddOrderBy(request, query, ModuleConstants.Metrics.ItemsViewed); return request; } @@ -96,7 +93,7 @@ protected virtual RunReportRequest CreateRequest(AnalyticsDataQuery query) { var request = new RunReportRequest { - Property = $"properties/{query.PropertyId}", + Property = AnalyticsFilterBuilder.PropertyName(query.PropertyId), Limit = query.Take > 0 ? query.Take : 1, Offset = query.Skip, }; @@ -123,7 +120,7 @@ protected virtual void AddDateHourAndExtraDimensions(RunReportRequest request, A request.Dimensions.Add(new Dimension { Name = ModuleConstants.Dimensions.DateHour }); } - foreach (var dimensionName in (query.DimensionNames ?? Array.Empty()) + foreach (var dimensionName in (query.DimensionNames ?? []) .Where(x => x != ModuleConstants.Dimensions.EventName && x != ModuleConstants.Dimensions.DateHour)) { request.Dimensions.Add(new Dimension { Name = MapDimensionName(dimensionName) }); @@ -149,30 +146,18 @@ protected virtual FilterExpression BuildDimensionFilter(AnalyticsDataQuery query { var expressions = new List(); - if (query.EventNames?.Count > 0) + if (!query.EventNames.IsNullOrEmpty()) { - expressions.Add(CreateInListExpression(ModuleConstants.Dimensions.EventName, query.EventNames)); + expressions.Add(AnalyticsFilterBuilder.CreateInListExpression(ModuleConstants.Dimensions.EventName, query.EventNames)); } - foreach (var filter in (query.DimensionFilters ?? Array.Empty()) - .Where(x => !string.IsNullOrEmpty(x.DimensionName) && x.Values?.Count > 0)) + foreach (var filter in (query.DimensionFilters ?? []) + .Where(x => !string.IsNullOrEmpty(x.DimensionName) && !x.Values.IsNullOrEmpty())) { - expressions.Add(CreateInListExpression(MapDimensionName(filter.DimensionName), filter.Values)); + expressions.Add(AnalyticsFilterBuilder.CreateInListExpression(MapDimensionName(filter.DimensionName), filter.Values)); } - if (expressions.Count == 0) - { - return null; - } - - if (expressions.Count == 1) - { - return expressions[0]; - } - - var result = new FilterExpression { AndGroup = new FilterExpressionList() }; - result.AndGroup.Expressions.AddRange(expressions); - return result; + return AnalyticsFilterBuilder.Combine(expressions); } protected virtual AnalyticsEventSearchResult MapResponse(RunReportResponse response, AnalyticsDataQuery query) @@ -230,15 +215,13 @@ protected virtual void MapDimensionValue(AnalyticsEvent analyticsEvent, string d protected virtual string MapDimensionName(string dimensionName) { - return ModuleConstants.UserDimensions.AllNames.Contains(dimensionName) - ? UserDimensionPrefix + dimensionName - : dimensionName; + return AnalyticsFilterBuilder.MapDimensionName(dimensionName); } protected virtual string UnmapDimensionName(string dimensionName) { - return dimensionName.StartsWith(UserDimensionPrefix, StringComparison.Ordinal) - ? dimensionName[UserDimensionPrefix.Length..] + return dimensionName.StartsWith(ModuleConstants.UserDimensions.Prefix, StringComparison.Ordinal) + ? dimensionName[ModuleConstants.UserDimensions.Prefix.Length..] : dimensionName; } @@ -253,17 +236,6 @@ protected static bool IsCountSort(AnalyticsDataQuery query) return ModuleConstants.SortBy.Count.EqualsIgnoreCase(query.SortBy); } - private static FilterExpression CreateInListExpression(string fieldName, IEnumerable values) - { - var inListFilter = new Filter.Types.InListFilter(); - inListFilter.Values.AddRange(values); - - return new FilterExpression - { - Filter = new Filter { FieldName = fieldName, InListFilter = inListFilter }, - }; - } - private static DateTime? ParseDateHour(string value) { return DateTime.TryParseExact(value, DateHourFormat, CultureInfo.InvariantCulture, diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs index a6106e7..daa53f5 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs @@ -1,3 +1,5 @@ +using System; +using System.Threading; using System.Threading.Tasks; using Google.Analytics.Data.V1Beta; using Google.Apis.Auth.OAuth2; @@ -8,40 +10,61 @@ public class GoogleAnalyticsReportClient : IGoogleAnalyticsReportClient { private static readonly string[] AnalyticsReadOnlyScopes = { "https://www.googleapis.com/auth/analytics.readonly" }; + // This client is a singleton and BetaAnalyticsDataClient is thread-safe. An explicitly supplied + // GoogleCredential opts the builder out of the shared GAX channel pool, so building per call would leak + // an undisposed gRPC channel and start every call with a cold OAuth token cache. + private Lazy> _credential; + private Lazy> _client; + + public GoogleAnalyticsReportClient() + { + ResetCache(); + } + public virtual Task ValidateCredentialAsync() { - return ResolveCredentialAsync(); + return GetCredentialAsync(); } public virtual async Task GetMetadataAsync(string propertyId) { - var client = await CreateClientAsync(); + var client = await GetClientAsync(); return await client.GetMetadataAsync(new GetMetadataRequest { Name = $"properties/{propertyId}/metadata" }); } public virtual async Task RunReportAsync(RunReportRequest request) { - var client = await CreateClientAsync(); + var client = await GetClientAsync(); return await client.RunReportAsync(request); } public virtual async Task RunRealtimeReportAsync(RunRealtimeReportRequest request) { - var client = await CreateClientAsync(); + var client = await GetClientAsync(); return await client.RunRealtimeReportAsync(request); } public virtual async Task CheckCompatibilityAsync(CheckCompatibilityRequest request) { - var client = await CreateClientAsync(); + var client = await GetClientAsync(); return await client.CheckCompatibilityAsync(request); } + protected virtual Task GetClientAsync() + { + return AwaitOrResetAsync(_client); + } + + protected virtual Task GetCredentialAsync() + { + return AwaitOrResetAsync(_credential); + } + protected virtual async Task CreateClientAsync() { var builder = new BetaAnalyticsDataClientBuilder { - GoogleCredential = await ResolveCredentialAsync(), + GoogleCredential = await GetCredentialAsync(), }; return await builder.BuildAsync(); @@ -60,4 +83,25 @@ protected virtual Task GetApplicationDefaultCredentialAsync() { return GoogleCredential.GetApplicationDefaultAsync(); } + + // A faulted Lazy would replay the same failure for the lifetime of this singleton, which would leave + // diagnostics permanently red after the operator fixes the credentials. + private async Task AwaitOrResetAsync(Lazy> lazy) + { + try + { + return await lazy.Value; + } + catch + { + ResetCache(); + throw; + } + } + + private void ResetCache() + { + _credential = new Lazy>(ResolveCredentialAsync, LazyThreadSafetyMode.ExecutionAndPublication); + _client = new Lazy>(CreateClientAsync, LazyThreadSafetyMode.ExecutionAndPublication); + } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs deleted file mode 100644 index 476389a..0000000 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Threading.Tasks; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; - -namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; - -public interface IAnalyticsDataSource -{ - Task GetRowsAsync(AnalyticsDataQuery query); -} diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs index 8824ee0..9a98cd3 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs @@ -29,17 +29,6 @@ public class AnalyticsDiagnosticsServiceTests private const string StoreId = "test-store"; private const string PropertyId = "123456"; - private static readonly string[] StageOrder = - { - Stages.Configuration, - Stages.Credentials, - Stages.ApiAccess, - Stages.CustomDimensions, - Stages.ReportCompatibility, - Stages.Realtime, - Stages.ProcessedData, - }; - private static readonly string[] ExpectedReportDimensions = { "eventName", "dateHour", "searchTerm" }; private static readonly string[] ExpectedFilteredDimensions = { "eventName", "customUser:session_kind", "customUser:organization_id" }; private static readonly string[] ExpectedEventNameFilterValues = { "search", "view_search_results" }; @@ -49,7 +38,6 @@ public class AnalyticsDiagnosticsServiceTests private readonly Mock _reportClientMock = new(); private readonly List _capturedCompatibilityRequests = new(); private readonly List _capturedRealtimeRequests = new(); - private readonly List _capturedReportRequests = new(); [Fact] public async Task RunAsync_HappyPath_AllStagesPassInStableOrder() @@ -509,7 +497,6 @@ private void SetupReport(RunReportResponse response) { _reportClientMock .Setup(x => x.RunReportAsync(It.IsAny())) - .Callback((RunReportRequest request) => _capturedReportRequests.Add(request)) .ReturnsAsync(response); } @@ -594,6 +581,6 @@ private static AnalyticsDiagnosticsCheck GetCheck(AnalyticsDiagnosticsResult res private static void AssertStageOrder(AnalyticsDiagnosticsResult result) { - Assert.Equal(StageOrder, result.Checks.Select(x => x.Stage)); + Assert.Equal(ModuleConstants.DiagnosticsStages.AllStages, result.Checks.Select(x => x.Stage)); } } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index 35ac3d4..73a4e4d 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -97,6 +97,20 @@ public async Task SearchEventsAsync_DifferentCriteria_QueriesSourceAgain() _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } + [Fact] + public async Task SearchEventsAsync_DifferentDimensionFilterValues_UseDifferentCacheKeys() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateGoogleConfiguredService(); + + await service.SearchEventsAsync(CreateSearchCriteria(organizationId: "org1")); + await service.SearchEventsAsync(CreateSearchCriteria(organizationId: "org2")); + + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); + } + [Fact] public async Task SearchEventsAsync_ReturnsClonedResult() { @@ -255,15 +269,25 @@ private AnalyticsService CreateGoogleConfiguredService(TimeSpan? failureCacheTtl return CreateService(new AnalyticsDataApiSettings { PropertyId = "123456" }, failureCacheTtl); } - private static AnalyticsEventSearchCriteria CreateSearchCriteria() + private static AnalyticsEventSearchCriteria CreateSearchCriteria(string organizationId = null) { - return new AnalyticsEventSearchCriteria + var criteria = new AnalyticsEventSearchCriteria { StoreId = StoreId, EventNames = new List { ModuleConstants.EventNames.Search }, To = To, Take = 20, }; + + if (organizationId != null) + { + criteria.DimensionFilters = new List + { + new() { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = new List { organizationId } }, + }; + } + + return criteria; } private static AnalyticsEventSummaryCriteria CreateSummaryCriteria(params string[] eventNames) From e42ff860c10b539b9219407ac4312bcde45dcee7 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Fri, 28 Aug 2026 11:30:25 -0500 Subject: [PATCH 12/36] Refactoring --- docs/index.md | 79 +++++++++++++++++++ .../Services/AnalyticsService.cs | 9 ++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/index.md b/docs/index.md index 8cf5043..777c4e2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -68,6 +68,54 @@ Google Analytics 4 module defines the following store settings: 1. **GoogleAnalytics4.MeasurementId** - Google Analytics 4 Measurement ID (e.g., `G-XXXXXXXXXX`) 1. **GoogleAnalytics4.GTMContainerId** - Google Tag Manager Container ID (e.g., `GTM-XXXXXXX`) +### Reporting (Data API) settings + +These settings are **not public** — they are never returned by the anonymous `GET /api/googleanalytics/{storeId}` +endpoint. They configure *reading* from GA4 (see [Reading analytics data](#reading-analytics-data)), and are only +needed if another module consumes `IAnalyticsService`. + +1. **GoogleAnalytics4.DataApi.PropertyId** - the **numeric** GA4 property id to report on (GA4 Admin > Property + Settings > Property Details), e.g. `123456789`. This is *not* the `G-XXXXXXXXXX` measurement id. +1. **GoogleAnalytics4.DataApi.CacheTtlMinutes** - how long a successful report is cached per store and query + (default `60`). Data API tokens are metered per property per day, so caching is a quota requirement rather than + tuning; failed reports are cached for a fixed 60 seconds so a misconfiguration cannot burn quota. + +## Reading analytics data + +Besides tagging, the module can **read** GA4 through the Data API (`runReport`) and expose the result in-process as +`IAnalyticsService` (events, dimensions, filters and date ranges — no domain concepts). It has no GraphQL surface; +consumers build their own fields on top of it. + +### Prerequisites + +1. Set **GoogleAnalytics4.DataApi.PropertyId** for the store. +1. Enable the **Google Analytics Data API** (`analyticsdata.googleapis.com`) in the Google Cloud project the + credential belongs to. +1. Provide credentials through **Application Default Credentials** — there is no credential setting. In a cluster + this is workload identity or the metadata server; with a key file it is `GOOGLE_APPLICATION_CREDENTIALS`. For + local development, the login must include the analytics scope (a plain `gcloud auth application-default login` + does **not**, and every call then fails with `PermissionDenied`): + + ```sh + gcloud auth application-default login --scopes="https://www.googleapis.com/auth/analytics.readonly,https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email,openid" + gcloud auth application-default set-quota-project + ``` + +1. Grant the credential's principal the **Viewer** role on the GA4 property (GA4 Admin > Property access management). +1. Register any **user-scoped custom dimensions** a consumer filters on in GA4 Admin > Custom definitions. + Registration is **not retroactive** — only events collected after it are reportable. + +### What this source can and cannot answer + +* GA4 processes events for up to **24-48 hours** before `runReport` can see them. "No rows" right after tagging is + the expected state, not a fault. +* Reports are **aggregates**: the finest time dimension is `dateHour`, so every timestamp is an hour-bucket start, + never an event time. +* Coverage is a **sample**, not a record — ad blockers and consent mode mean GA sees a subset of real activity. +* GA4 suppresses rows for small cohorts when **Google Signals** is enabled, which is exactly the shape of a + single-customer query. A property used for per-customer reporting typically needs Signals off. + + ## Rest API ### Get Google Analytics Settings @@ -91,5 +139,36 @@ Response: ### Update Google Analytics Settings Use Store API to provide management above Google Analytics Settings. +### Run connection diagnostics + +Endpoint: `/api/googleanalytics/{storeId}/diagnostics` + +Method: `POST`, permission: `googleanalytics:access` + +Runs a staged check of the reporting setup and returns one row per stage — `configuration`, `credentials`, +`apiAccess`, `customDimensions`, `reportCompatibility`, `realtime`, `processedData` — each with a `status` of +`Passed`, `Warning`, `Failed` or `Skipped`, a message naming the fix, and an optional `detail`. A failure in one of +the first three stages marks the rest `Skipped`, so the response shape never varies. Diagnostics bypasses the +response cache and reads Google directly, and never reports a credential's contents — only its kind. + +The request body is optional; every field defaults, so `{}` runs a bare connectivity check: + +```jsonc +{ + // user-scoped custom dimensions to verify, WITHOUT the customUser: prefix + "userDimensionNames": ["contact_id", "organization_id", "session_kind"], + "eventNames": ["search", "view_item", "login"], // events you expect to be collected + "reports": [{ // report shapes to compatibility-check + "name": "searchTerms", + "dimensionNames": ["eventName", "dateHour", "searchTerm"], + "metricName": "eventCount", + "eventNames": ["search", "view_search_results"] + }], + "includeLiveData": true // false skips the two data stages, saving Data API quota +} +``` + +Empty results are reported as `Warning`, not `Failed` — "no data yet" is a state, not a fault. + ## Troubleshoting [Enable debug mode](https://support.google.com/analytics/answer/7201382) so you can see events in realtime and more easily troubleshoot issues. diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 0667a27..9bc0854 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -118,7 +118,7 @@ protected virtual async Task GetOrCreateAsync( } catch (Exception ex) { - _logger.LogWarning(ex, "Google Analytics {Operation} failed for store {StoreId}", operation, criteria.StoreId); + LogFailure(operation, criteria.StoreId, ex); cacheOptions.AbsoluteExpirationRelativeToNow = FailureCacheTtl; return createEmptyResult(); } @@ -126,7 +126,7 @@ protected virtual async Task GetOrCreateAsync( } catch (Exception ex) { - _logger.LogWarning(ex, "Google Analytics {Operation} failed for store {StoreId}", operation, criteria.StoreId); + LogFailure(operation, criteria.StoreId, ex); return createEmptyResult(); } } @@ -188,4 +188,9 @@ protected virtual TimeSpan GetCacheTtl(AnalyticsDataApiSettings settings) { return TimeSpan.FromMinutes(Math.Max(1, settings.CacheTtlMinutes)); } + + private void LogFailure(string operation, string storeId, Exception exception) + { + _logger.LogWarning(exception, "Google Analytics {Operation} failed for store {StoreId}", operation, storeId); + } } From c65e83c75f23815d11103a2ee3e6edaeb32bb7da Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Fri, 28 Aug 2026 12:00:52 -0500 Subject: [PATCH 13/36] Refactoring --- .../ModuleConstants.cs | 6 ------ .../Services/AnalyticsService.cs | 10 +++++----- .../Services/GoogleAnalyticsDataSource.cs | 2 +- .../Services/IAnalyticsDataSource.cs | 12 ++++++++++++ .../Module.cs | 2 +- 5 files changed, 19 insertions(+), 13 deletions(-) create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index 6da89e7..c6c7a28 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -39,12 +39,6 @@ public static class UserDimensions public static string[] AllNames { get; } = { ContactId, OrganizationId, OrganizationName, IsSalesRep, SessionKind }; } - public static class SessionKinds - { - public const string Self = "self"; - public const string Impersonated = "impersonated"; - } - public static class Dimensions { public const string SearchTerm = "searchTerm"; diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 9bc0854..7733dec 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -19,18 +19,18 @@ public class AnalyticsService : IAnalyticsService private readonly IAnalyticsSettingsResolver _settingsResolver; private readonly IPlatformMemoryCache _platformMemoryCache; - private readonly GoogleAnalyticsDataSource _googleAnalyticsDataSource; + private readonly IAnalyticsDataSource _dataSource; private readonly ILogger _logger; public AnalyticsService( IAnalyticsSettingsResolver settingsResolver, IPlatformMemoryCache platformMemoryCache, - GoogleAnalyticsDataSource googleAnalyticsDataSource, + IAnalyticsDataSource dataSource, ILogger logger) { _settingsResolver = settingsResolver; _platformMemoryCache = platformMemoryCache; - _googleAnalyticsDataSource = googleAnalyticsDataSource; + _dataSource = dataSource; _logger = logger; } @@ -65,7 +65,7 @@ public virtual async Task SearchEventsAsync(Analytic query.Take = criteria.Take; query.Skip = criteria.Skip; - return _googleAnalyticsDataSource.GetRowsAsync(query); + return _dataSource.GetRowsAsync(query); }, AbstractTypeFactory.TryCreateInstance); @@ -84,7 +84,7 @@ public virtual async Task> GetEventSummariesAsync(A var query = CreateQuery(settings, criteria); query.Take = SummaryRowsLimit; - var rows = await _googleAnalyticsDataSource.GetRowsAsync(query); + var rows = await _dataSource.GetRowsAsync(query); return CreateSummaries(criteria, rows.Events); }, () => CreateEmptySummaries(criteria)); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs index a87dcc9..2dd6301 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs @@ -11,7 +11,7 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; -public class GoogleAnalyticsDataSource +public class GoogleAnalyticsDataSource : IAnalyticsDataSource { private const string NotSetValue = "(not set)"; private const string DateHourFormat = "yyyyMMddHH"; diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs new file mode 100644 index 0000000..c5f625d --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs @@ -0,0 +1,12 @@ +using System.Threading.Tasks; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; + +// The provider seam behind IAnalyticsService: register a different implementation to answer the same queries +// from another source (a first-party event store, a BigQuery export) without changing a consumer. +public interface IAnalyticsDataSource +{ + Task GetRowsAsync(AnalyticsDataQuery query); +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs index c3575a5..074e07e 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Module.cs @@ -22,7 +22,7 @@ public void Initialize(IServiceCollection serviceCollection) { serviceCollection.AddTransient(); serviceCollection.AddSingleton(); - serviceCollection.AddTransient(); + serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); serviceCollection.AddTransient(); From d4e9fe5340da9acd6f85e045dfc9c9b0bccb489b Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Fri, 28 Aug 2026 12:20:13 -0500 Subject: [PATCH 14/36] Refactoring (optimization) --- .../Services/AnalyticsService.cs | 58 ++++++++++++++++--- .../AnalyticsServiceTests.cs | 39 +++++++++++++ 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 7733dec..f7e3c13 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; @@ -13,7 +14,12 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; public class AnalyticsService : IAnalyticsService { - private const int SummaryRowsLimit = 100_000; + // Count mode returns one row per event name, so the totals read is bounded by the names asked for — or, when + // the caller names none, by GA4's per-property cap on distinct event names. + private const int MaxEventNames = 500; + + // Date mode orders by dateHour descending, so the newest bucket is the first row. + private const int LatestBucketProbeSize = 1; private const string SearchOperation = "events search"; private const string SummariesOperation = "event summaries"; @@ -79,14 +85,7 @@ public virtual async Task> GetEventSummariesAsync(A var result = await GetOrCreateAsync( SummariesOperation, criteria, - async settings => - { - var query = CreateQuery(settings, criteria); - query.Take = SummaryRowsLimit; - - var rows = await _dataSource.GetRowsAsync(query); - return CreateSummaries(criteria, rows.Events); - }, + settings => CreateSummariesAsync(settings, criteria), () => CreateEmptySummaries(criteria)); return result.Select(x => x.CloneTyped()).ToList(); @@ -131,6 +130,47 @@ protected virtual async Task GetOrCreateAsync( } } + // A summary is a sum and a newest-occurrence per event name, and GA has no "max(dateHour)" aggregation — so + // reducing a fetched series here would transfer one row per event name PER HOUR (years of rows) to produce two + // numbers. Two narrow reads answer it instead: 'count' mode collapses to one row per event name carrying the + // summed metric, and a one-row 'date' probe per event name carries its newest bucket. Names with no events at + // all are not probed. + protected virtual async Task> CreateSummariesAsync( + AnalyticsDataApiSettings settings, + AnalyticsEventSummaryCriteria criteria) + { + var totalsQuery = CreateQuery(settings, criteria); + totalsQuery.SortBy = ModuleConstants.SortBy.Count; + totalsQuery.Take = criteria.EventNames.IsNullOrEmpty() ? MaxEventNames : criteria.EventNames.Count; + + var totals = await _dataSource.GetRowsAsync(totalsQuery); + + // Count-mode rows carry no date, so the summaries come back with a null LastOccurredAt that the probe fills. + var summaries = CreateSummaries(criteria, totals.Events); + + foreach (var summary in summaries.Where(x => x.TotalCount > 0)) + { + summary.LastOccurredAt = await GetLastOccurredAtAsync(settings, criteria, summary.EventName); + } + + return summaries; + } + + protected virtual async Task GetLastOccurredAtAsync( + AnalyticsDataApiSettings settings, + AnalyticsEventSummaryCriteria criteria, + string eventName) + { + var query = CreateQuery(settings, criteria); + query.EventNames = [eventName]; + query.SortBy = ModuleConstants.SortBy.Date; + query.Take = LatestBucketProbeSize; + + var rows = await _dataSource.GetRowsAsync(query); + + return rows.Events.Where(x => x.EventName == eventName).Max(x => x.OccurredAt); + } + protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settings, AnalyticsEventCriteriaBase criteria) { var query = AbstractTypeFactory.TryCreateInstance(); diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index 73a4e4d..79418d0 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -217,6 +217,45 @@ public async Task GetEventSummariesAsync_AggregatesPerRequestedEventName() Assert.Null(signUp.LastOccurredAt); } + [Fact] + public async Task GetEventSummariesAsync_ReadsNarrowly_NeverTheWholeHourlySeries() + { + var queries = new List(); + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .Callback(queries.Add) + .ReturnsAsync(CreateSearchResult( + ("search", To.AddHours(-2), 2), + ("search", To, 3), + ("login", To.AddHours(-1), 5))); + var service = CreateGoogleConfiguredService(); + + var criteria = CreateSummaryCriteria( + ModuleConstants.EventNames.Search, + ModuleConstants.EventNames.Login, + ModuleConstants.EventNames.SignUp); + await service.GetEventSummariesAsync(criteria); + + // One totals read for all three names, then a newest-bucket probe only for the two that have events: + // sign_up totals zero, so nothing is asked about its last occurrence. + Assert.Equal(3, queries.Count); + + var totals = queries[0]; + Assert.Equal(ModuleConstants.SortBy.Count, totals.SortBy); + Assert.Equal(3, totals.Take); + Assert.Equal(criteria.EventNames, totals.EventNames); + + var probes = queries.Skip(1).ToList(); + Assert.All(probes, x => Assert.Equal(ModuleConstants.SortBy.Date, x.SortBy)); + Assert.All(probes, x => Assert.Equal(1, x.Take)); + Assert.Equal( + new[] { ModuleConstants.EventNames.Search, ModuleConstants.EventNames.Login }, + probes.Select(x => Assert.Single(x.EventNames))); + + // The point of the shape: no read is proportional to the number of hours in the range. + Assert.All(queries, x => Assert.True(x.Take <= 3)); + } + [Fact] public async Task GetEventSummariesAsync_NotConfigured_ReturnsZeroSummariesPerRequestedName() { From ec9334da09c77be056574b74adfb266d676187b0 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Fri, 28 Aug 2026 13:08:16 -0500 Subject: [PATCH 15/36] Refactoring Compare GA event names with EqualsIgnoreCase, as everywhere else in the module. Co-Authored-By: Claude Opus 5 --- .../Services/AnalyticsService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index f7e3c13..23702eb 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -168,7 +168,7 @@ protected virtual async Task> CreateSummariesAsync( var rows = await _dataSource.GetRowsAsync(query); - return rows.Events.Where(x => x.EventName == eventName).Max(x => x.OccurredAt); + return rows.Events.Where(x => x.EventName.EqualsIgnoreCase(eventName)).Max(x => x.OccurredAt); } protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settings, AnalyticsEventCriteriaBase criteria) From dbbb180a56fdbd0e10b05251b303b9fc06f34311 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Fri, 28 Aug 2026 13:14:12 -0500 Subject: [PATCH 16/36] Refactoring (optimization) Run the newest-bucket probes concurrently instead of nose to tail. The degree is capped because GA4 limits concurrent requests per property and a criteria naming no events can carry up to MaxEventNames of them. Co-Authored-By: Claude Opus 5 --- .../Services/AnalyticsService.cs | 15 ++++++++++---- .../AnalyticsServiceTests.cs | 20 +++++++++++-------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 23702eb..381ec96 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -20,6 +20,10 @@ public class AnalyticsService : IAnalyticsService // Date mode orders by dateHour descending, so the newest bucket is the first row. private const int LatestBucketProbeSize = 1; + + // The probes are independent, so they run together instead of nose to tail. Capped rather than unbounded: + // GA4 limits concurrent requests per property, and a criteria naming no events can carry MaxEventNames. + private const int MaxProbeConcurrency = 4; private const string SearchOperation = "events search"; private const string SummariesOperation = "event summaries"; @@ -148,10 +152,13 @@ protected virtual async Task> CreateSummariesAsync( // Count-mode rows carry no date, so the summaries come back with a null LastOccurredAt that the probe fills. var summaries = CreateSummaries(criteria, totals.Events); - foreach (var summary in summaries.Where(x => x.TotalCount > 0)) - { - summary.LastOccurredAt = await GetLastOccurredAtAsync(settings, criteria, summary.EventName); - } + await Parallel.ForEachAsync( + summaries.Where(x => x.TotalCount > 0), + new ParallelOptions { MaxDegreeOfParallelism = MaxProbeConcurrency }, + async (summary, _) => + { + summary.LastOccurredAt = await GetLastOccurredAtAsync(settings, criteria, summary.EventName); + }); return summaries; } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index 79418d0..18fe489 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -220,10 +221,11 @@ public async Task GetEventSummariesAsync_AggregatesPerRequestedEventName() [Fact] public async Task GetEventSummariesAsync_ReadsNarrowly_NeverTheWholeHourlySeries() { - var queries = new List(); + // Concurrent, because the probes run in parallel; enqueue order still puts the awaited totals read first. + var queries = new ConcurrentQueue(); _googleDataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) - .Callback(queries.Add) + .Callback(queries.Enqueue) .ReturnsAsync(CreateSearchResult( ("search", To.AddHours(-2), 2), ("search", To, 3), @@ -238,22 +240,24 @@ public async Task GetEventSummariesAsync_ReadsNarrowly_NeverTheWholeHourlySeries // One totals read for all three names, then a newest-bucket probe only for the two that have events: // sign_up totals zero, so nothing is asked about its last occurrence. - Assert.Equal(3, queries.Count); + var reads = queries.ToList(); + Assert.Equal(3, reads.Count); - var totals = queries[0]; + var totals = reads[0]; Assert.Equal(ModuleConstants.SortBy.Count, totals.SortBy); Assert.Equal(3, totals.Take); Assert.Equal(criteria.EventNames, totals.EventNames); - var probes = queries.Skip(1).ToList(); + var probes = reads.Skip(1).ToList(); Assert.All(probes, x => Assert.Equal(ModuleConstants.SortBy.Date, x.SortBy)); Assert.All(probes, x => Assert.Equal(1, x.Take)); + // Unordered: the probes run concurrently. Assert.Equal( - new[] { ModuleConstants.EventNames.Search, ModuleConstants.EventNames.Login }, - probes.Select(x => Assert.Single(x.EventNames))); + new[] { ModuleConstants.EventNames.Login, ModuleConstants.EventNames.Search }, + probes.Select(x => Assert.Single(x.EventNames)).OrderBy(x => x, StringComparer.Ordinal)); // The point of the shape: no read is proportional to the number of hours in the range. - Assert.All(queries, x => Assert.True(x.Take <= 3)); + Assert.All(reads, x => Assert.True(x.Take <= 3)); } [Fact] From 8c65a46552fed6136c37dbe1632cf9d94af01b3d Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Fri, 28 Aug 2026 13:22:06 -0500 Subject: [PATCH 17/36] Refactoring (optimization) Bound an unnamed summary at 50 event names, not GA4's 500-name property cap: a summary over hundreds of names is a reporting question, not a feature read, and this is what the probe fan-out is bounded by. Co-Authored-By: Claude Opus 5 --- .../Services/AnalyticsService.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 381ec96..6b62844 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -15,8 +15,9 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; public class AnalyticsService : IAnalyticsService { // Count mode returns one row per event name, so the totals read is bounded by the names asked for — or, when - // the caller names none, by GA4's per-property cap on distinct event names. - private const int MaxEventNames = 500; + // the caller names none, by this. GA4 allows a property up to 500 distinct event names, but a summary over + // hundreds of them is a reporting question, not a feature read: this bounds the probe fan-out below with it. + private const int MaxEventNames = 50; // Date mode orders by dateHour descending, so the newest bucket is the first row. private const int LatestBucketProbeSize = 1; From 2879ba44cee240ae340dee3ea23c6dd1023574e3 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Mon, 31 Aug 2026 07:48:08 -0500 Subject: [PATCH 18/36] Refactoring --- .../Services/AnalyticsDiagnosticsService.cs | 2 +- .../AnalyticsServiceTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs index 68e5671..c4059f5 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -364,7 +364,7 @@ protected virtual CheckCompatibilityRequest BuildCompatibilityRequest(string pro } expressions.AddRange(userDimensionNames - .Select(x => AnalyticsFilterBuilder.CreateInListExpression(ModuleConstants.UserDimensions.Prefix + x, new[] { ProbeFilterValue }))); + .Select(x => AnalyticsFilterBuilder.CreateInListExpression(ModuleConstants.UserDimensions.Prefix + x, [ProbeFilterValue]))); var dimensionFilter = AnalyticsFilterBuilder.Combine(expressions); if (dimensionFilter != null) diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index 18fe489..765117c 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -253,7 +253,7 @@ public async Task GetEventSummariesAsync_ReadsNarrowly_NeverTheWholeHourlySeries Assert.All(probes, x => Assert.Equal(1, x.Take)); // Unordered: the probes run concurrently. Assert.Equal( - new[] { ModuleConstants.EventNames.Login, ModuleConstants.EventNames.Search }, + [ModuleConstants.EventNames.Login, ModuleConstants.EventNames.Search], probes.Select(x => Assert.Single(x.EventNames)).OrderBy(x => x, StringComparer.Ordinal)); // The point of the shape: no read is proportional to the number of hours in the range. From 1ad9bd3f04576e7c169e320ede4f2d96dbad476a Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Mon, 31 Aug 2026 08:19:49 -0500 Subject: [PATCH 19/36] README.md --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 1884e0e..38f5c2d 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,16 @@ Google Analytics 4 module allows you to assign Google Analytics Measurement Id a The module supports both direct GA4 integration and advanced Google Tag Manager integration for centralized tag management. +It also **reads back** from GA4 through the Data API, so other modules can ask what the collected events say — see [Reading analytics data](#reading-analytics-data). + ## Key Features 1. Store Configuration. 1. Measure ecommerce with Vue B2B Theme and Virto Storefront. 1. Google Tag Manager integration for advanced tracking and tag management. 1. Ready for integration with other sales channels. 1. Application menu. +1. Reporting API: read collected events back from GA4 (`IAnalyticsService`) for other modules to build on. +1. Staged connection diagnostics for the reporting setup. ## Screenshots ![Google Analytics 4](docs/media/ga4-realtime.png) @@ -107,6 +111,23 @@ The module adds `Google Analytics` link into Application menu. It redirects to G ![Google Analytics 4 App Menu](docs/media/app-menu.png) +## Reading analytics data + +Besides tagging, the module can **read** GA4 through the Data API (`runReport`) and expose the result in-process as +`IAnalyticsService` — events, dimensions, filters and date ranges, with no domain concepts of its own. It has no +GraphQL surface; consumers build their own fields on top of it (the first is +[VirtoCommerce.SalesRep](https://github.com/VirtoCommerce/vc-module-sales-rep), which turns it into rep-facing +customer insights). + +Reporting needs two extra store settings — the numeric **GA4 property id** and a report cache lifetime — plus +**Application Default Credentials** with the `analytics.readonly` scope and Viewer on the property. There is +deliberately no credential *setting*, so no key material is stored in the platform database. +`POST api/googleanalytics/{storeId}/diagnostics` runs a staged checklist that names exactly what is missing. + +Two limits are properties of the source rather than of this module: GA4 takes up to **24-48 hours** to process +events, and reports are **aggregates** whose finest time dimension is the hour. Setup steps, the settings, the +credential prerequisites and the diagnostics payload are documented in [docs/index.md](docs/index.md). + ## Documentation * [Google Analytics 4](https://developers.google.com/analytics/devguides/collection/ga4) * [Google Tag Manager](https://developers.google.com/tag-platform/tag-manager) From 5a275e7cdf3c3ddb790bd748cd851229a73e9c25 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Wed, 2 Sep 2026 13:40:30 -0500 Subject: [PATCH 20/36] Refactoring (/vc-module-review) --- .../Models/AnalyticsDiagnosticsReportShape.cs | 4 +- .../Models/AnalyticsDiagnosticsRequest.cs | 6 +- .../Models/AnalyticsDiagnosticsResult.cs | 2 +- .../Models/AnalyticsDimensionFilter.cs | 2 +- .../Models/AnalyticsEventCriteriaBase.cs | 4 +- .../Models/AnalyticsEventSearchCriteria.cs | 2 +- .../Models/AnalyticsEventSearchResult.cs | 2 +- .../Models/AnalyticsDataQuery.cs | 6 +- .../Services/AnalyticsDiagnosticsService.cs | 2 +- .../Services/AnalyticsFilterBuilder.cs | 2 + .../Services/AnalyticsService.cs | 2 +- .../GoogleAnalyticsControllerTests.cs | 103 ++++++++++++++++++ .../GoogleAnalyticsReportClientTests.cs | 42 +++++++ 13 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsControllerTests.cs diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsReportShape.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsReportShape.cs index 8fd904e..b042f73 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsReportShape.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsReportShape.cs @@ -6,9 +6,9 @@ public class AnalyticsDiagnosticsReportShape { public string Name { get; set; } - public IList DimensionNames { get; set; } = new List(); + public IList DimensionNames { get; set; } = []; public string MetricName { get; set; } - public IList EventNames { get; set; } = new List(); + public IList EventNames { get; set; } = []; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsRequest.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsRequest.cs index 8507134..874da74 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsRequest.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsRequest.cs @@ -4,11 +4,11 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; public class AnalyticsDiagnosticsRequest { - public IList UserDimensionNames { get; set; } = new List(); + public IList UserDimensionNames { get; set; } = []; - public IList EventNames { get; set; } = new List(); + public IList EventNames { get; set; } = []; - public IList Reports { get; set; } = new List(); + public IList Reports { get; set; } = []; public bool IncludeLiveData { get; set; } = true; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsResult.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsResult.cs index 33b86c5..2ca4b59 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsResult.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDiagnosticsResult.cs @@ -4,5 +4,5 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; public class AnalyticsDiagnosticsResult { - public IList Checks { get; set; } = new List(); + public IList Checks { get; set; } = []; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs index b4b51d1..5e10aad 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsDimensionFilter.cs @@ -7,5 +7,5 @@ public class AnalyticsDimensionFilter : ValueObject { public string DimensionName { get; set; } - public IList Values { get; set; } = new List(); + public IList Values { get; set; } = []; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs index 4312177..3ba31f3 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs @@ -8,9 +8,9 @@ public abstract class AnalyticsEventCriteriaBase : SearchCriteriaBase { public string StoreId { get; set; } - public IList EventNames { get; set; } = new List(); + public IList EventNames { get; set; } = []; - public IList DimensionFilters { get; set; } = new List(); + public IList DimensionFilters { get; set; } = []; public DateTime? From { get; set; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs index 846ecd3..a3dde34 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchCriteria.cs @@ -4,7 +4,7 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; public class AnalyticsEventSearchCriteria : AnalyticsEventCriteriaBase { - public IList DimensionNames { get; set; } = new List(); + public IList DimensionNames { get; set; } = []; public string SortBy { get; set; } = ModuleConstants.SortBy.Date; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs index 973a8e1..39a8c94 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs @@ -9,7 +9,7 @@ public class AnalyticsEventSearchResult : ICloneable { public int TotalCount { get; set; } - public IList Events { get; set; } = new List(); + public IList Events { get; set; } = []; public virtual object Clone() { diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs index 96f7412..bb193da 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs @@ -9,11 +9,11 @@ public class AnalyticsDataQuery { public string PropertyId { get; set; } - public IList EventNames { get; set; } = new List(); + public IList EventNames { get; set; } = []; - public IList DimensionFilters { get; set; } = new List(); + public IList DimensionFilters { get; set; } = []; - public IList DimensionNames { get; set; } = new List(); + public IList DimensionNames { get; set; } = []; public string SortBy { get; set; } = ModuleConstants.SortBy.Date; diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs index c4059f5..7aa6065 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -463,7 +463,7 @@ protected virtual IList> AggregateEventCounts(IEnumer .Select(x => (int?)x.Index) .FirstOrDefault(); - var counts = new Dictionary(); + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var row in rows) { diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs index ddb585e..bfe1076 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs @@ -13,6 +13,8 @@ public static string PropertyName(string propertyId) return $"properties/{propertyId}"; } + // Ordinal on purpose: GA4 field names are case-sensitive, so a mis-cased name is rejected by the API + // whether or not it is prefixed, and the response echoes back the exact name that was sent. public static string MapDimensionName(string dimensionName, IList extraUserDimensionNames = null) { return ModuleConstants.UserDimensions.AllNames.Contains(dimensionName) || extraUserDimensionNames?.Contains(dimensionName) == true diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 6b62844..d9209db 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -200,7 +200,7 @@ protected virtual IList CreateEmptySummaries(AnalyticsEve protected virtual IList CreateSummaries(AnalyticsEventSummaryCriteria criteria, IList events) { - var aggregates = new Dictionary(); + var aggregates = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var analyticsEvent in events.Where(x => !string.IsNullOrEmpty(x.EventName))) { diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsControllerTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsControllerTests.cs new file mode 100644 index 0000000..dd556cf --- /dev/null +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsControllerTests.cs @@ -0,0 +1,103 @@ +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Web.Controllers.Api; +using Xunit; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; + +/// +/// What each action declares about who may call it. The platform sets DefaultPolicy but no FallbackPolicy, so an +/// action without an [Authorize] is reachable anonymously - which is right for the tag settings the storefront +/// renders on every page, and wrong for diagnostics, which names the property, the credential kind and the Google +/// errors behind a failure. The difference is asserted here rather than assumed. +/// +public class GoogleAnalyticsControllerTests +{ + [Fact] + public void RunDiagnostics_DeclaresTheAccessPermission() + { + var attribute = GetAuthorizeAttributes(nameof(GoogleAnalyticsController.RunDiagnostics)).Single(); + + Assert.Equal(ModuleConstants.Security.Permissions.Access, attribute.Policy); + } + + [Fact] + public void Redirect_DeclaresTheAccessPermission() + { + var attribute = GetAuthorizeAttributes(nameof(GoogleAnalyticsController.Redirect)).Single(); + + Assert.Equal(ModuleConstants.Security.Permissions.Access, attribute.Policy); + } + + // The storefront reads it before anyone signs in, and it carries only the public tag identifiers. + [Fact] + public void GetStoreSettings_IsReachableAnonymously() + { + Assert.Empty(GetAuthorizeAttributes(nameof(GoogleAnalyticsController.GetStoreSettings))); + Assert.Empty(typeof(GoogleAnalyticsController).GetCustomAttributes()); + } + + [Fact] + public async Task RunDiagnostics_PassesTheStoreAndRequestThrough_AndReturnsTheReport() + { + var report = new AnalyticsDiagnosticsResult + { + Checks = [new AnalyticsDiagnosticsCheck { Stage = ModuleConstants.DiagnosticsStages.Configuration }], + }; + var diagnosticsService = new FakeDiagnosticsService(report); + var controller = new GoogleAnalyticsController(new FakeSettingsManager(), null, diagnosticsService); + var request = new AnalyticsDiagnosticsRequest { EventNames = [ModuleConstants.EventNames.Search] }; + + var response = await controller.RunDiagnostics("B2B-store", request); + + Assert.Same(report, Assert.IsType(response.Result).Value); + Assert.Equal("B2B-store", diagnosticsService.StoreId); + Assert.Same(request, diagnosticsService.Request); + } + + private static AuthorizeAttribute[] GetAuthorizeAttributes(string actionName) + { + // DeclaredOnly: the Redirect action shares its name with ControllerBase.Redirect(string). + return typeof(GoogleAnalyticsController) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Single(x => x.Name == actionName) + .GetCustomAttributes() + .ToArray(); + } + + private sealed class FakeDiagnosticsService : IAnalyticsDiagnosticsService + { + private readonly AnalyticsDiagnosticsResult _report; + + public FakeDiagnosticsService(AnalyticsDiagnosticsResult report) + { + _report = report; + } + + public string StoreId { get; private set; } + + public AnalyticsDiagnosticsRequest Request { get; private set; } + + public Task RunAsync(string storeId, AnalyticsDiagnosticsRequest request) + { + StoreId = storeId; + Request = request; + + return Task.FromResult(_report); + } + } + + private sealed class FakeSettingsManager : IGoogleAnalyticsSettingsManager + { + public Task GetAsync(string storeId) + { + return Task.FromResult(new GoogleAnalyticsSettings()); + } + } +} diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs index 11fa9b4..f5f2a3e 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs @@ -1,3 +1,4 @@ +using System; using System.Security.Cryptography; using System.Threading.Tasks; using Google.Apis.Auth.OAuth2; @@ -36,6 +37,32 @@ public async Task ResolveCredentialAsync_AlreadyScopedCredential_ReturnsItUnchan Assert.False(credential.IsCreateScopedRequired); } + // The client is a singleton and caches the credential in a Lazy. A faulted Lazy replays its failure + // forever, so a credential that fails once would leave diagnostics red even after the operator fixes ADC. + [Fact] + public async Task GetCredentialAsync_AfterAFailedResolve_ResolvesAgainInsteadOfReplayingTheFailure() + { + var client = new TestableReportClient(CreateUnscopedCredential()) { FailNextAdcCall = true }; + + await Assert.ThrowsAsync(() => client.GetCachedCredentialAsync()); + var credential = await client.GetCachedCredentialAsync(); + + Assert.NotNull(credential); + Assert.Equal(2, client.AdcCallCount); + } + + [Fact] + public async Task GetCredentialAsync_AfterASuccessfulResolve_IsCached() + { + var client = new TestableReportClient(CreateUnscopedCredential()); + + var first = await client.GetCachedCredentialAsync(); + var second = await client.GetCachedCredentialAsync(); + + Assert.Same(first, second); + Assert.Equal(1, client.AdcCallCount); + } + private static GoogleCredential CreateUnscopedCredential() { using var rsa = RSA.Create(PrivateKeySizeInBits); @@ -60,14 +87,29 @@ public TestableReportClient(GoogleCredential applicationDefaultCredential) public int AdcCallCount { get; private set; } + public bool FailNextAdcCall { get; set; } + public Task ResolveAsync() { return ResolveCredentialAsync(); } + // The cached path, the one a singleton actually takes on every call. + public Task GetCachedCredentialAsync() + { + return GetCredentialAsync(); + } + protected override Task GetApplicationDefaultCredentialAsync() { AdcCallCount++; + + if (FailNextAdcCall) + { + FailNextAdcCall = false; + throw new InvalidOperationException("The Application Default Credentials are not available."); + } + return Task.FromResult(_applicationDefaultCredential); } } From 217d57e72ce070015f7f6b6d2c1596d20963d613 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Wed, 9 Sep 2026 11:46:58 -0500 Subject: [PATCH 21/36] Refactoring (after alexeyshibanov review) --- docs/index.md | 7 +- module.ignore | 14 ++ .../Models/AnalyticsEventCriteriaBase.cs | 4 + .../Services/IAnalyticsService.cs | 3 + .../Models/AnalyticsDataApiSettings.cs | 5 +- .../Services/AnalyticsDiagnosticsService.cs | 47 ++++-- .../Services/AnalyticsService.cs | 101 +++++++++++-- .../Services/AnalyticsSettingsResolver.cs | 35 ++++- .../Services/GoogleAnalyticsDataSource.cs | 53 +++++-- .../AnalyticsDiagnosticsServiceTests.cs | 60 ++++++-- .../AnalyticsServiceTests.cs | 137 +++++++++++++++++- .../AnalyticsSettingsResolverTests.cs | 19 ++- .../GoogleAnalyticsDataSourceTests.cs | 73 ++++++++++ 13 files changed, 498 insertions(+), 60 deletions(-) diff --git a/docs/index.md b/docs/index.md index 777c4e2..a1091a9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -149,7 +149,12 @@ Runs a staged check of the reporting setup and returns one row per stage — `co `apiAccess`, `customDimensions`, `reportCompatibility`, `realtime`, `processedData` — each with a `status` of `Passed`, `Warning`, `Failed` or `Skipped`, a message naming the fix, and an optional `detail`. A failure in one of the first three stages marks the rest `Skipped`, so the response shape never varies. Diagnostics bypasses the -response cache and reads Google directly, and never reports a credential's contents — only its kind. +response cache and reads Google directly. + +Key material never reaches the response: the `credentials` stage reports only the *kind* of credential in use. +`detail`, however, forwards the underlying error verbatim, and Google's own messages can name the environment +around the credential — the path in `GOOGLE_APPLICATION_CREDENTIALS`, or the Cloud project behind a disabled +API. The endpoint is gated on `googleanalytics:access` for that reason. The request body is optional; every field defaults, so `{}` runs a bare connectivity check: diff --git a/module.ignore b/module.ignore index 43c5fa7..1b0d943 100644 --- a/module.ignore +++ b/module.ignore @@ -1 +1,15 @@ +Microsoft.Bcl.AsyncInterfaces.dll +Newtonsoft.Json.dll +Serilog.dll +System.CodeDom.dll +System.IO.Abstractions.dll +System.Management.dll +Testably.Abstractions.FileSystem.Interface.dll +TestableIO.System.IO.Abstractions.dll +TestableIO.System.IO.Abstractions.Wrappers.dll TimeZoneConverter.dll +VirtoCommerce.CoreModule.Core.dll +VirtoCommerce.NotificationsModule.Core.dll +VirtoCommerce.Platform.Core.dll +VirtoCommerce.Seo.Core.dll +VirtoCommerce.StoreModule.Core.dll diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs index 3ba31f3..40d0783 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs @@ -15,4 +15,8 @@ public abstract class AnalyticsEventCriteriaBase : SearchCriteriaBase public DateTime? From { get; set; } public DateTime? To { get; set; } + + // Reads Google directly instead of the response cache. For a caller whose whole purpose is the live state + // (a diagnostics probe); a feature read must not set it, since the cache is what protects the API quota. + public bool BypassCache { get; set; } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs index 8465d85..b66e489 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs @@ -8,6 +8,9 @@ public interface IAnalyticsService { Task IsConfiguredAsync(string storeId); + // Naming an item-scoped dimension (itemId / itemName / itemListName) switches the report to the item scope: + // the metric becomes items viewed rather than event occurrences, so AnalyticsEvent.Count means that instead — + // and EventName is only filled in when the criteria named exactly one. Task SearchEventsAsync(AnalyticsEventSearchCriteria criteria); Task> GetEventSummariesAsync(AnalyticsEventSummaryCriteria criteria); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs index cc54941..d2ce091 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs @@ -1,10 +1,13 @@ +using DataApiSettings = VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.ModuleConstants.Settings.DataApi; + namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; public class AnalyticsDataApiSettings { public string PropertyId { get; set; } - public int CacheTtlMinutes { get; set; } + // The descriptor's default, so an instance built without one caches rather than reading 0 as "disabled". + public int CacheTtlMinutes { get; set; } = DataApiSettings.DefaultCacheTtlMinutes; public bool IsConfigured => !string.IsNullOrEmpty(PropertyId); } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs index 7aa6065..339196d 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -276,10 +276,13 @@ protected virtual async Task CheckRealtimeAsync(AnalyticsDataApiSettings setting { var userDimensionNames = GetUserDimensionNames(request); + // The rejection names WHICH dimension GA4 refused, which is the whole answer this stage exists to give. + // Kept across the retry so it is still reported when the retry succeeds, and when it fails too. + RpcException dimensionFailure = null; + try { RunRealtimeReportResponse response; - var usedFallback = false; try { @@ -288,29 +291,35 @@ protected virtual async Task CheckRealtimeAsync(AnalyticsDataApiSettings setting } catch (RpcException ex) when (userDimensionNames.Count > 0 && ex.StatusCode == StatusCode.InvalidArgument) { - usedFallback = true; + dimensionFailure = ex; response = await _reportClient.RunRealtimeReportAsync( BuildRealtimeRequest(settings.PropertyId, [])); } - var fallbackNote = usedFallback + var fallbackNote = dimensionFailure != null ? " Realtime does not support the custom dimensions on this property — checked event stream only." : string.Empty; + var detail = dimensionFailure != null ? DescribeError(dimensionFailure) : null; var eventCounts = AggregateEventCounts(response.DimensionHeaders, response.Rows); + var missingNames = GetMissingRequestedEvents(request, eventCounts); if (eventCounts.Count == 0) { - AddCheck(checks, Stages.Realtime, Statuses.Warning, "No events in the last 30 minutes." + fallbackNote); + AddCheck(checks, Stages.Realtime, Statuses.Warning, "No events in the last 30 minutes." + fallbackNote, detail); } else { - AddCheck(checks, Stages.Realtime, Statuses.Passed, - $"Events in the last 30 minutes: {FormatEventCounts(eventCounts)}.{fallbackNote}{DescribeMissingRequestedEvents(request, eventCounts)}"); + AddCheck(checks, Stages.Realtime, GetDataStatus(missingNames), + $"Events in the last 30 minutes: {FormatEventCounts(eventCounts)}.{fallbackNote}{DescribeMissingRequestedEvents(missingNames)}", + detail); } } catch (Exception ex) { - AddCheck(checks, Stages.Realtime, Statuses.Failed, "Realtime report failed.", DescribeError(ex)); + AddCheck(checks, Stages.Realtime, Statuses.Failed, "Realtime report failed.", + dimensionFailure == null + ? DescribeError(ex) + : $"{DescribeError(ex)} (the first attempt, with the custom dimensions, failed with: {DescribeError(dimensionFailure)})"); } } @@ -328,8 +337,10 @@ protected virtual async Task CheckProcessedDataAsync(AnalyticsDataApiSettings se } else { - AddCheck(checks, Stages.ProcessedData, Statuses.Passed, - $"Processed events over the last 7 days: {FormatEventCounts(eventCounts)}.{DescribeMissingRequestedEvents(request, eventCounts)}"); + var missingNames = GetMissingRequestedEvents(request, eventCounts); + + AddCheck(checks, Stages.ProcessedData, GetDataStatus(missingNames), + $"Processed events over the last 7 days: {FormatEventCounts(eventCounts)}.{DescribeMissingRequestedEvents(missingNames)}"); } } catch (Exception ex) @@ -490,13 +501,23 @@ protected virtual string FormatEventCounts(IList> eve return string.Join(", ", eventCounts.Select(x => FormattableString.Invariant($"{x.Key}={x.Value}"))); } - protected virtual string DescribeMissingRequestedEvents(AnalyticsDiagnosticsRequest request, IList> eventCounts) + protected virtual IList GetMissingRequestedEvents(AnalyticsDiagnosticsRequest request, IList> eventCounts) { - var missingNames = (request.EventNames ?? []) - .Where(x => !string.IsNullOrEmpty(x) && eventCounts.All(count => count.Key != x)) - .Distinct() + return (request.EventNames ?? []) + .Where(x => !string.IsNullOrEmpty(x) && !eventCounts.Any(count => count.Key.EqualsIgnoreCase(x))) + .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); + } + // An operator who asked "is view_item being collected?" and is not seeing it has their answer in the status, + // not in a sentence appended to a green row. + protected virtual string GetDataStatus(IList missingNames) + { + return missingNames.Count > 0 ? Statuses.Warning : Statuses.Passed; + } + + protected virtual string DescribeMissingRequestedEvents(IList missingNames) + { return missingNames.Count > 0 ? $" Requested events not seen: {string.Join(", ", missingNames)}." : string.Empty; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index d9209db..75b9773 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; @@ -25,6 +26,9 @@ public class AnalyticsService : IAnalyticsService // The probes are independent, so they run together instead of nose to tail. Capped rather than unbounded: // GA4 limits concurrent requests per property, and a criteria naming no events can carry MaxEventNames. private const int MaxProbeConcurrency = 4; + // The platform expresses Caching:CacheEnabled=false as a one-tick TTL on the default entry options. + private static readonly TimeSpan CacheDisabled = TimeSpan.FromTicks(1); + private const string SearchOperation = "events search"; private const string SummariesOperation = "event summaries"; @@ -65,6 +69,8 @@ public virtual async Task SearchEventsAsync(Analytic { ArgumentNullException.ThrowIfNull(criteria); + criteria = WithNormalizedDates(criteria); + var result = await GetOrCreateAsync( SearchOperation, criteria, @@ -87,6 +93,8 @@ public virtual async Task> GetEventSummariesAsync(A { ArgumentNullException.ThrowIfNull(criteria); + criteria = WithNormalizedDates(criteria); + var result = await GetOrCreateAsync( SummariesOperation, criteria, @@ -110,22 +118,19 @@ protected virtual async Task GetOrCreateAsync( return createEmptyResult(); } + // Diagnostics asks for the live state, so it opts out rather than reading a cached verdict. + if (criteria.BypassCache) + { + return await ReadAsync(operation, criteria, factory, createEmptyResult, settings, null); + } + var cacheKey = CacheKey.With(GetType(), operation, criteria.GetCacheKey()); - return await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, async cacheOptions => + return await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, cacheOptions => { - cacheOptions.AbsoluteExpirationRelativeToNow = GetCacheTtl(settings); + ApplyCacheTtl(cacheOptions, GetCacheTtl(settings)); - try - { - return await factory(settings); - } - catch (Exception ex) - { - LogFailure(operation, criteria.StoreId, ex); - cacheOptions.AbsoluteExpirationRelativeToNow = FailureCacheTtl; - return createEmptyResult(); - } + return ReadAsync(operation, criteria, factory, createEmptyResult, settings, cacheOptions); }); } catch (Exception ex) @@ -135,6 +140,48 @@ protected virtual async Task GetOrCreateAsync( } } + private async Task ReadAsync( + string operation, + AnalyticsEventCriteriaBase criteria, + Func> factory, + Func createEmptyResult, + AnalyticsDataApiSettings settings, + MemoryCacheEntryOptions cacheOptions) + { + try + { + return await factory(settings); + } + catch (Exception ex) + { + LogFailure(operation, criteria.StoreId, ex); + + if (cacheOptions != null) + { + ApplyCacheTtl(cacheOptions, FailureCacheTtl); + } + + return createEmptyResult(); + } + } + + // The request carries dates, so two criteria differing only in time of day are the same Google query — + // while GetCacheKey() renders From/To at second precision and would miss the hit on a metered API. + protected virtual T WithNormalizedDates(T criteria) + where T : AnalyticsEventCriteriaBase + { + if (criteria.From?.TimeOfDay == TimeSpan.Zero && criteria.To?.TimeOfDay == TimeSpan.Zero) + { + return criteria; + } + + var result = criteria.CloneTyped(); + result.From = criteria.From?.Date; + result.To = criteria.To?.Date; + + return result; + } + // A summary is a sum and a newest-occurrence per event name, and GA has no "max(dateHour)" aggregation — so // reducing a fetched series here would transfer one row per event name PER HOUR (years of rows) to produce two // numbers. Two narrow reads answer it instead: 'count' mode collapses to one row per event name carrying the @@ -158,7 +205,16 @@ await Parallel.ForEachAsync( new ParallelOptions { MaxDegreeOfParallelism = MaxProbeConcurrency }, async (summary, _) => { - summary.LastOccurredAt = await GetLastOccurredAtAsync(settings, criteria, summary.EventName); + try + { + summary.LastOccurredAt = await GetLastOccurredAtAsync(settings, criteria, summary.EventName); + } + catch (Exception ex) + { + // The totals read already succeeded. Faulting the loop would discard every count and cache + // the zeros, so a failed probe costs its own last-occurrence and nothing else. + LogFailure($"last occurrence of '{summary.EventName}'", criteria.StoreId, ex); + } }); return summaries; @@ -192,7 +248,8 @@ protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settin return query; } - // Not an empty list: requested event names still yield zero-count summaries. + // Requested event names still yield zero-count summaries. A criteria naming NO names has nothing to shape + // them from, so that one does come back empty. protected virtual IList CreateEmptySummaries(AnalyticsEventSummaryCriteria criteria) { return CreateSummaries(criteria, []); @@ -234,7 +291,21 @@ protected virtual IList CreateSummaries(AnalyticsEventSum protected virtual TimeSpan GetCacheTtl(AnalyticsDataApiSettings settings) { - return TimeSpan.FromMinutes(Math.Max(1, settings.CacheTtlMinutes)); + return TimeSpan.FromMinutes(settings.CacheTtlMinutes); + } + + // Mirrors SalesRep's StatisticsCache.Apply: the platform hands the factory its DEFAULT entry options, so a + // TTL written straight over them makes Caching:CacheEnabled=false inert and leaves the platform's sliding + // default (15 min) to evict before the TTL this module documents as a setting. + protected virtual void ApplyCacheTtl(MemoryCacheEntryOptions options, TimeSpan ttl) + { + if (options.AbsoluteExpirationRelativeToNow == CacheDisabled) + { + return; + } + + options.SlidingExpiration = null; + options.AbsoluteExpirationRelativeToNow = ttl > TimeSpan.Zero ? ttl : CacheDisabled; } private void LogFailure(string operation, string storeId, Exception exception) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs index b7fe48d..70d2536 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; using VirtoCommerce.Platform.Core.Common; using VirtoCommerce.Platform.Core.Settings; @@ -13,11 +15,16 @@ public class AnalyticsSettingsResolver : IAnalyticsSettingsResolver { private readonly IStoreService _storeService; private readonly ISettingsManager _settingsManager; + private readonly ILogger _logger; - public AnalyticsSettingsResolver(IStoreService storeService, ISettingsManager settingsManager) + public AnalyticsSettingsResolver( + IStoreService storeService, + ISettingsManager settingsManager, + ILogger logger) { _storeService = storeService; _settingsManager = settingsManager; + _logger = logger; } public virtual async Task ResolveAsync(string storeId) @@ -35,8 +42,28 @@ public virtual async Task ResolveAsync(string storeId) protected virtual async Task GetSettingAsync(ICollection storeSettings, SettingDescriptor descriptor) { - return storeSettings?.Any(x => x.Name.EqualsIgnoreCase(descriptor.Name) && x.Value != null) == true - ? storeSettings.GetValue(descriptor) - : await _settingsManager.GetValueAsync(descriptor); + // Not just null: the admin UI writes "" for a cleared ShortText, and taking that as an override would + // make clearing a store-level value disable reporting instead of restoring the global one. + var hasStoreValue = storeSettings?.Any(x => + x.Name.EqualsIgnoreCase(descriptor.Name) && + x.Value is not null and not "") == true; + + if (!hasStoreValue) + { + return await _settingsManager.GetValueAsync(descriptor); + } + + try + { + return storeSettings.GetValue(descriptor); + } + catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) + { + // A store value of the wrong shape is a misconfiguration, not a reason to fail the read. + _logger.LogWarning(ex, "Store setting {Setting} is not a valid {Type}; falling back to the global value", + descriptor.Name, typeof(T).Name); + + return await _settingsManager.GetValueAsync(descriptor); + } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs index 2dd6301..724f1d6 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs @@ -151,9 +151,18 @@ protected virtual FilterExpression BuildDimensionFilter(AnalyticsDataQuery query expressions.Add(AnalyticsFilterBuilder.CreateInListExpression(ModuleConstants.Dimensions.EventName, query.EventNames)); } - foreach (var filter in (query.DimensionFilters ?? []) - .Where(x => !string.IsNullOrEmpty(x.DimensionName) && !x.Values.IsNullOrEmpty())) + foreach (var filter in (query.DimensionFilters ?? []).Where(x => !string.IsNullOrEmpty(x.DimensionName))) { + // Dropping a valueless filter turns a scoping constraint into no constraint at all — and these carry + // the consumer's data isolation, so the read would silently widen to every organization. An empty + // list is the caller's bug, and this is the last place that can still refuse it. + if (filter.Values.IsNullOrEmpty()) + { + throw new ArgumentException( + $"Dimension filter '{filter.DimensionName}' carries no values, which would leave the read unscoped.", + nameof(query)); + } + expressions.Add(AnalyticsFilterBuilder.CreateInListExpression(MapDimensionName(filter.DimensionName), filter.Values)); } @@ -171,6 +180,7 @@ protected virtual AnalyticsEventSearchResult MapResponse(RunReportResponse respo } var headers = response.DimensionHeaders.Select(x => x.Name).ToList(); + var timeZone = ResolvePropertyTimeZone(response.Metadata?.TimeZone); foreach (var row in response.Rows) { @@ -178,7 +188,7 @@ protected virtual AnalyticsEventSearchResult MapResponse(RunReportResponse respo for (var i = 0; i < headers.Count && i < row.DimensionValues.Count; i++) { - MapDimensionValue(analyticsEvent, headers[i], row.DimensionValues[i].Value); + MapDimensionValue(analyticsEvent, headers[i], row.DimensionValues[i].Value, timeZone); } analyticsEvent.Count = row.MetricValues.Count > 0 @@ -192,7 +202,7 @@ protected virtual AnalyticsEventSearchResult MapResponse(RunReportResponse respo return result; } - protected virtual void MapDimensionValue(AnalyticsEvent analyticsEvent, string dimensionName, string value) + protected virtual void MapDimensionValue(AnalyticsEvent analyticsEvent, string dimensionName, string value, TimeZoneInfo timeZone) { if (string.IsNullOrEmpty(value) || value == NotSetValue) { @@ -205,7 +215,7 @@ protected virtual void MapDimensionValue(AnalyticsEvent analyticsEvent, string d } else if (dimensionName == ModuleConstants.Dimensions.DateHour) { - analyticsEvent.OccurredAt = ParseDateHour(value); + analyticsEvent.OccurredAt = ParseDateHour(value, timeZone); } else { @@ -236,11 +246,34 @@ protected static bool IsCountSort(AnalyticsDataQuery query) return ModuleConstants.SortBy.Count.EqualsIgnoreCase(query.SortBy); } - private static DateTime? ParseDateHour(string value) + // GA4 reports dateHour in the PROPERTY's reporting timezone — the proto documents ResponseMetaData.time_zone + // as being there "to interpret time-based dimensions like hour and minute" — and it ships that zone with every + // response. Assuming UTC instead put every bucket out by the property's offset, silently. + protected virtual TimeZoneInfo ResolvePropertyTimeZone(string timeZoneId) { - return DateTime.TryParseExact(value, DateHourFormat, CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var result) - ? result - : null; + if (string.IsNullOrEmpty(timeZoneId)) + { + return TimeZoneInfo.Utc; + } + + // GA gives an IANA id, which .NET resolves on every platform since ICU. An id it cannot resolve leaves + // the buckets as they were read rather than failing the whole report. + return TimeZoneInfo.TryFindSystemTimeZoneById(timeZoneId, out var timeZone) ? timeZone : TimeZoneInfo.Utc; + } + + private static DateTime? ParseDateHour(string value, TimeZoneInfo timeZone) + { + if (!DateTime.TryParseExact(value, DateHourFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out var result)) + { + return null; + } + + var local = DateTime.SpecifyKind(result, DateTimeKind.Unspecified); + + // The hour a spring-forward skips has no UTC instant; GA cannot bucket into it, but a mis-set property + // could, and converting one throws. + return timeZone.IsInvalidTime(local) + ? DateTime.SpecifyKind(local, DateTimeKind.Utc) + : TimeZoneInfo.ConvertTimeToUtc(local, timeZone); } } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs index 9a98cd3..b760758 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs @@ -58,9 +58,12 @@ public async Task RunAsync_HappyPath_AllStagesPassInStableOrder() Assert.Contains("session_kind", GetCheck(result, Stages.CustomDimensions).Message); Assert.Contains("searchTerms", GetCheck(result, Stages.ReportCompatibility).Message); + // A happy path sees every event the request asks about, so nothing is "not seen" here — the two + // Requested*NotSeen tests own that case. var realtime = GetCheck(result, Stages.Realtime); Assert.Contains("search=5", realtime.Message); - Assert.Contains("Requested events not seen: view_item", realtime.Message); + Assert.Contains("view_item=1", realtime.Message); + Assert.DoesNotContain("Requested events not seen", realtime.Message); Assert.DoesNotContain("checked event stream only", realtime.Message); var processedData = GetCheck(result, Stages.ProcessedData); @@ -407,18 +410,20 @@ public async Task Realtime_HardFailure_FailsRealtimeOnlyProcessedStillRuns() Assert.Equal(Statuses.Passed, GetCheck(result, Stages.ProcessedData).Status); } + // Asserted as behaviour, not as a constructor signature: caching inside the report client would leave a + // signature check green while diagnostics reported a stale verdict, which is the failure that matters. [Fact] - public void Constructor_BypassesAnalyticsCacheAndService() + public async Task RunAsync_CalledTwiceWithTheSameArguments_ReadsGoogleEachTime() { - var parameterTypes = typeof(AnalyticsDiagnosticsService) - .GetConstructors() - .SelectMany(x => x.GetParameters()) - .Select(x => x.ParameterType) - .ToList(); + SetupHappyGooglePath(); + var service = CreateService(); - Assert.DoesNotContain(typeof(IPlatformMemoryCache), parameterTypes); - Assert.DoesNotContain(typeof(IAnalyticsService), parameterTypes); - Assert.DoesNotContain(typeof(AnalyticsService), parameterTypes); + await service.RunAsync(StoreId, CreateRequest()); + await service.RunAsync(StoreId, CreateRequest()); + + _reportClientMock.Verify(x => x.GetMetadataAsync(It.IsAny()), Times.Exactly(2)); + _reportClientMock.Verify(x => x.RunRealtimeReportAsync(It.IsAny()), Times.Exactly(2)); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny()), Times.Exactly(2)); } private async Task RunApiAccessFailureAsync(RpcException exception) @@ -438,6 +443,39 @@ private async Task RunApiAccessFailureAsync(RpcExcept return apiAccess; } + // A green row plus a sentence is not an answer: a UI colours by status, so an event the operator asked + // about and is not seeing has to move the status. + [Fact] + public async Task Realtime_RequestedEventNotSeen_WarnsAndNamesIt() + { + SetupHappyGooglePath(); + SetupRealtime(CreateRealtimeResponse(("search", "2"))); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + var realtime = GetCheck(result, Stages.Realtime); + Assert.Equal(Statuses.Warning, realtime.Status); + Assert.Contains("view_item", realtime.Message); + // The stage still reports what it DID see, and the run continues. + Assert.Contains("search=2", realtime.Message); + Assert.Equal(Statuses.Passed, GetCheck(result, Stages.ProcessedData).Status); + } + + [Fact] + public async Task ProcessedData_RequestedEventNotSeen_WarnsAndNamesIt() + { + SetupHappyGooglePath(); + SetupReport(CreateReportResponse(("search", "10"))); + var service = CreateService(); + + var result = await service.RunAsync(StoreId, CreateRequest()); + + var processed = GetCheck(result, Stages.ProcessedData); + Assert.Equal(Statuses.Warning, processed.Status); + Assert.Contains("view_item", processed.Message); + } + private AnalyticsDiagnosticsService CreateService() { return new AnalyticsDiagnosticsService(_settingsResolverMock.Object, _reportClientMock.Object); @@ -448,7 +486,7 @@ private void SetupHappyGooglePath() SetupGoogleSettings(); SetupMetadata("customUser:session_kind", "customUser:organization_id"); SetupCompatibility(new CheckCompatibilityResponse()); - SetupRealtime(CreateRealtimeResponse(("search", "2"), ("search", "3"))); + SetupRealtime(CreateRealtimeResponse(("search", "2"), ("search", "3"), ("view_item", "1"))); SetupReport(CreateReportResponse(("search", "10"), ("view_item", "4"))); } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index 765117c..6c7b4a9 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -218,6 +218,39 @@ public async Task GetEventSummariesAsync_AggregatesPerRequestedEventName() Assert.Null(signUp.LastOccurredAt); } + // The totals read has already succeeded by the time the probes run. Faulting the whole loop would throw + // those counts away and cache the zeros, so one bad probe would report "no activity" for every event. + [Fact] + public async Task GetEventSummariesAsync_OneProbeFails_KeepsTheTotalsAndOnlyLosesThatLastOccurrence() + { + var totals = CreateCountModeResult(("search", 3), ("login", 5)); + + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.Is(q => q.SortBy == ModuleConstants.SortBy.Count))) + .ReturnsAsync(totals); + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.Is(q => + q.SortBy != ModuleConstants.SortBy.Count && q.EventNames.Contains(ModuleConstants.EventNames.Login)))) + .ThrowsAsync(new InvalidOperationException("GA responded 429")); + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.Is(q => + q.SortBy != ModuleConstants.SortBy.Count && q.EventNames.Contains(ModuleConstants.EventNames.Search)))) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + + var service = CreateGoogleConfiguredService(); + + var summaries = await service.GetEventSummariesAsync( + CreateSummaryCriteria(ModuleConstants.EventNames.Search, ModuleConstants.EventNames.Login)); + + var search = summaries.First(x => x.EventName == ModuleConstants.EventNames.Search); + Assert.Equal(3, search.TotalCount); + Assert.Equal(To, search.LastOccurredAt); + + var login = summaries.First(x => x.EventName == ModuleConstants.EventNames.Login); + Assert.Equal(5, login.TotalCount); + Assert.Null(login.LastOccurredAt); + } + [Fact] public async Task GetEventSummariesAsync_ReadsNarrowly_NeverTheWholeHourlySeries() { @@ -289,7 +322,7 @@ public async Task GetEventSummariesAsync_SourceFails_CachesZeroSummariesForFailu _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); } - private AnalyticsService CreateService(AnalyticsDataApiSettings settings = null, TimeSpan? failureCacheTtl = null) + private AnalyticsService CreateService(AnalyticsDataApiSettings settings = null, TimeSpan? failureCacheTtl = null, bool cacheEnabled = true) { if (settings != null) { @@ -299,7 +332,7 @@ private AnalyticsService CreateService(AnalyticsDataApiSettings settings = null, } var memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); - var platformMemoryCache = new PlatformMemoryCache(memoryCache, Options.Create(new CachingOptions()), new Mock>().Object); + var platformMemoryCache = new PlatformMemoryCache(memoryCache, Options.Create(new CachingOptions { CacheEnabled = cacheEnabled }), new Mock>().Object); var logger = new Mock>().Object; return failureCacheTtl == null @@ -307,9 +340,93 @@ private AnalyticsService CreateService(AnalyticsDataApiSettings settings = null, : new ShortFailureTtlAnalyticsService(failureCacheTtl.Value, _settingsResolverMock.Object, platformMemoryCache, _googleDataSourceMock.Object, logger); } - private AnalyticsService CreateGoogleConfiguredService(TimeSpan? failureCacheTtl = null) + private AnalyticsService CreateGoogleConfiguredService(TimeSpan? failureCacheTtl = null, bool cacheEnabled = true) + { + return CreateService(new AnalyticsDataApiSettings { PropertyId = "123456" }, failureCacheTtl, cacheEnabled); + } + + // The request only carries dates, so two criteria differing by time of day are the SAME Google query — + // and the key is what decides whether the metered API is called twice for it. + [Fact] + public async Task SearchEventsAsync_CriteriaDifferingOnlyInTimeOfDay_UsesCache() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateGoogleConfiguredService(); + + var morning = CreateSearchCriteria(); + morning.To = To.Date.AddHours(9).AddMinutes(17); + var evening = CreateSearchCriteria(); + evening.To = To.Date.AddHours(21).AddSeconds(4); + + await service.SearchEventsAsync(morning); + await service.SearchEventsAsync(evening); + + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task SearchEventsAsync_NormalizingDates_DoesNotMutateTheCallersCriteria() { - return CreateService(new AnalyticsDataApiSettings { PropertyId = "123456" }, failureCacheTtl); + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateGoogleConfiguredService(); + + var criteria = CreateSearchCriteria(); + criteria.To = To.Date.AddHours(9).AddMinutes(17); + + await service.SearchEventsAsync(criteria); + + Assert.Equal(To.Date.AddHours(9).AddMinutes(17), criteria.To); + } + + // Diagnostics answers "is it working right now", so it must not be served a cached verdict. + [Fact] + public async Task SearchEventsAsync_BypassCache_ReadsEveryTime() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateGoogleConfiguredService(); + + var criteria = CreateSearchCriteria(); + criteria.BypassCache = true; + + await service.SearchEventsAsync(criteria); + await service.SearchEventsAsync(criteria); + + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); + } + + // The platform expresses Caching:CacheEnabled=false as a one-tick TTL on the options it hands the factory. + [Fact] + public async Task SearchEventsAsync_PlatformCachingDisabled_ReadsEveryTime() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateGoogleConfiguredService(cacheEnabled: false); + + await service.SearchEventsAsync(CreateSearchCriteria()); + await service.SearchEventsAsync(CreateSearchCriteria()); + + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task SearchEventsAsync_CacheTtlZero_ReadsEveryTime() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateService(new AnalyticsDataApiSettings { PropertyId = "123456", CacheTtlMinutes = 0 }); + + await service.SearchEventsAsync(CreateSearchCriteria()); + await service.SearchEventsAsync(CreateSearchCriteria()); + + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } private static AnalyticsEventSearchCriteria CreateSearchCriteria(string organizationId = null) @@ -343,6 +460,18 @@ private static AnalyticsEventSummaryCriteria CreateSummaryCriteria(params string }; } + // Count mode collapses to one row per event name and carries no date; the probes are what fill it in. + private static AnalyticsEventSearchResult CreateCountModeResult(params (string EventName, int Count)[] events) + { + return new AnalyticsEventSearchResult + { + TotalCount = events.Length, + Events = events + .Select(x => new AnalyticsEvent { EventName = x.EventName, Count = x.Count }) + .ToList(), + }; + } + private static AnalyticsEventSearchResult CreateSearchResult(params (string EventName, DateTime OccurredAt, int Count)[] events) { return new AnalyticsEventSearchResult diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs index 6810a17..77fea0e 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; @@ -64,6 +65,21 @@ public async Task ResolveAsync_FallsBackToGlobalSettings() Assert.Equal("global-property", settings.PropertyId); } + // Clearing a store-level override in the admin UI writes "", not nothing. Taking that as an override made + // clearing it DISABLE reporting instead of restoring the global property. + [Fact] + public async Task ResolveAsync_ClearedStoreValue_FallsBackToGlobal() + { + SetupGlobalSetting(ModuleConstants.Settings.DataApi.PropertyId.Name, "global-property"); + SetupStore(CreateStore((ModuleConstants.Settings.DataApi.PropertyId.Name, ""))); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(StoreId); + + Assert.Equal("global-property", settings.PropertyId); + Assert.True(settings.IsConfigured); + } + [Fact] public async Task ResolveAsync_NullStoreId_UsesGlobalSettingsWithoutLoadingStore() { @@ -90,7 +106,8 @@ public async Task ResolveAsync_NothingConfigured_ReturnsUnconfiguredSettings() private AnalyticsSettingsResolver CreateResolver() { - return new AnalyticsSettingsResolver(_storeServiceMock.Object, _settingsManagerMock.Object); + return new AnalyticsSettingsResolver(_storeServiceMock.Object, _settingsManagerMock.Object, + NullLogger.Instance); } private void SetupStore(Store store) diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs index 8865e8e..73b1e14 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs @@ -318,6 +318,79 @@ public async Task GetRowsAsync_ItemRows_MultipleEventNames_LeavesEventNameNull() Assert.Null(Assert.Single(result.Events).EventName); } + // GA4 reports dateHour in the PROPERTY's timezone and ships that zone with the response. Stamping the + // bucket UTC instead put every date on these screens out by the property's offset, silently. + [Fact] + public async Task GetRowsAsync_PropertyTimeZone_ConvertsTheHourBucketToUtc() + { + var dataSource = CreateDataSource(CreateDateHourResponse("2026082510", "America/Los_Angeles")); + + var result = await dataSource.GetRowsAsync(CreateDateHourQuery()); + + // 10:00 Pacific on 25 Aug 2026 is 17:00 UTC (PDT, UTC-7). + Assert.Equal(new DateTime(2026, 8, 25, 17, 0, 0, DateTimeKind.Utc), Assert.Single(result.Events).OccurredAt); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Not/AZone")] + public async Task GetRowsAsync_NoUsableTimeZone_LeavesTheBucketAsRead(string timeZoneId) + { + var dataSource = CreateDataSource(CreateDateHourResponse("2026082510", timeZoneId)); + + var result = await dataSource.GetRowsAsync(CreateDateHourQuery()); + + Assert.Equal(new DateTime(2026, 8, 25, 10, 0, 0, DateTimeKind.Utc), Assert.Single(result.Events).OccurredAt); + } + + // A filter with no values used to be dropped, which silently widened the read to every organization — + // the one failure mode these filters exist to prevent. + [Fact] + public async Task GetRowsAsync_DimensionFilterWithoutValues_Throws() + { + var dataSource = CreateDataSource(); + + var query = CreateDateHourQuery(); + query.DimensionFilters = new List + { + new() { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = new List() }, + }; + + var exception = await Assert.ThrowsAsync(() => dataSource.GetRowsAsync(query)); + Assert.Contains(ModuleConstants.UserDimensions.OrganizationId, exception.Message); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny()), Times.Never); + } + + private static AnalyticsDataQuery CreateDateHourQuery() + { + return new AnalyticsDataQuery + { + PropertyId = "123456", + EventNames = new List { ModuleConstants.EventNames.Search }, + Take = 10, + }; + } + + private static RunReportResponse CreateDateHourResponse(string dateHour, string timeZoneId) + { + var response = new RunReportResponse { RowCount = 1 }; + + if (timeZoneId != null) + { + response.Metadata = new ResponseMetaData { TimeZone = timeZoneId }; + } + + response.DimensionHeaders.Add(new DimensionHeader { Name = "dateHour" }); + + var row = new Row(); + row.DimensionValues.Add(new DimensionValue { Value = dateHour }); + row.MetricValues.Add(new MetricValue { Value = "1" }); + response.Rows.Add(row); + + return response; + } + private GoogleAnalyticsDataSource CreateDataSource(RunReportResponse response = null) { _reportClientMock From 2c217b2b01c1d16d98c08a8fd27699ac02f0dc6a Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Wed, 9 Sep 2026 12:29:05 -0500 Subject: [PATCH 22/36] Refactoring (/vc-module-review) --- docs/index.md | 9 +++-- .../Models/AnalyticsEventCriteriaBase.cs | 3 +- .../Services/IAnalyticsService.cs | 5 ++- .../Models/AnalyticsDataApiSettings.cs | 2 +- .../Services/AnalyticsDiagnosticsService.cs | 6 ++-- .../Services/AnalyticsService.cs | 25 +++++++------- .../Services/AnalyticsSettingsResolver.cs | 4 +-- .../Services/GoogleAnalyticsDataSource.cs | 34 ++++++++++++------- .../AnalyticsDiagnosticsServiceTests.cs | 11 +++--- .../AnalyticsServiceTests.cs | 15 ++++---- .../AnalyticsSettingsResolverTests.cs | 3 +- .../GoogleAnalyticsDataSourceTests.cs | 9 +++-- 12 files changed, 66 insertions(+), 60 deletions(-) diff --git a/docs/index.md b/docs/index.md index a1091a9..a7edf62 100644 --- a/docs/index.md +++ b/docs/index.md @@ -78,7 +78,10 @@ needed if another module consumes `IAnalyticsService`. Settings > Property Details), e.g. `123456789`. This is *not* the `G-XXXXXXXXXX` measurement id. 1. **GoogleAnalytics4.DataApi.CacheTtlMinutes** - how long a successful report is cached per store and query (default `60`). Data API tokens are metered per property per day, so caching is a quota requirement rather than - tuning; failed reports are cached for a fixed 60 seconds so a misconfiguration cannot burn quota. + tuning; failed reports are cached for a fixed 60 seconds so a misconfiguration cannot burn quota. Set it to + **`0`** to read Google on every call, and note that the platform's own `Caching:CacheEnabled=false` is honoured + here too - both are for diagnosing stale reporting data, not for normal operation. The cache key is the store + plus the query's *dates*, so two reads differing only in time of day share one entry. ## Reading analytics data @@ -110,7 +113,9 @@ consumers build their own fields on top of it. * GA4 processes events for up to **24-48 hours** before `runReport` can see them. "No rows" right after tagging is the expected state, not a fault. * Reports are **aggregates**: the finest time dimension is `dateHour`, so every timestamp is an hour-bucket start, - never an event time. + never an event time. GA4 reports those buckets in the **property's** reporting timezone; the module converts them + to UTC using the zone GA returns with each response, so `OccurredAt` is a real UTC instant. The `from`/`to` bounds + of a query are still sent as UTC dates, so a range *edge* can differ by up to a day from the property's calendar. * Coverage is a **sample**, not a record — ad blockers and consent mode mean GA sees a subset of real activity. * GA4 suppresses rows for small cohorts when **Google Signals** is enabled, which is exactly the shape of a single-customer query. A property used for per-customer reporting typically needs Signals off. diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs index 40d0783..531c433 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventCriteriaBase.cs @@ -16,7 +16,6 @@ public abstract class AnalyticsEventCriteriaBase : SearchCriteriaBase public DateTime? To { get; set; } - // Reads Google directly instead of the response cache. For a caller whose whole purpose is the live state - // (a diagnostics probe); a feature read must not set it, since the cache is what protects the API quota. + // For a diagnostics probe: a feature read must not set it, since the cache is what protects the quota. public bool BypassCache { get; set; } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs index b66e489..71e4d54 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs @@ -8,9 +8,8 @@ public interface IAnalyticsService { Task IsConfiguredAsync(string storeId); - // Naming an item-scoped dimension (itemId / itemName / itemListName) switches the report to the item scope: - // the metric becomes items viewed rather than event occurrences, so AnalyticsEvent.Count means that instead — - // and EventName is only filled in when the criteria named exactly one. + // An item-scoped dimension (itemId / itemName / itemListName) switches the report to the item scope: Count + // then means items viewed, not occurrences, and EventName is filled only when exactly one name was asked for. Task SearchEventsAsync(AnalyticsEventSearchCriteria criteria); Task> GetEventSummariesAsync(AnalyticsEventSummaryCriteria criteria); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs index d2ce091..61ee926 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs @@ -6,7 +6,7 @@ public class AnalyticsDataApiSettings { public string PropertyId { get; set; } - // The descriptor's default, so an instance built without one caches rather than reading 0 as "disabled". + // The descriptor's default: 0 now means "disabled", so an unset instance must not land on it. public int CacheTtlMinutes { get; set; } = DataApiSettings.DefaultCacheTtlMinutes; public bool IsConfigured => !string.IsNullOrEmpty(PropertyId); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs index 339196d..c30659d 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -276,8 +276,7 @@ protected virtual async Task CheckRealtimeAsync(AnalyticsDataApiSettings setting { var userDimensionNames = GetUserDimensionNames(request); - // The rejection names WHICH dimension GA4 refused, which is the whole answer this stage exists to give. - // Kept across the retry so it is still reported when the retry succeeds, and when it fails too. + // The rejection names WHICH dimension GA4 refused; kept so the retry cannot swallow it. RpcException dimensionFailure = null; try @@ -509,8 +508,7 @@ protected virtual IList GetMissingRequestedEvents(AnalyticsDiagnosticsRe .ToList(); } - // An operator who asked "is view_item being collected?" and is not seeing it has their answer in the status, - // not in a sentence appended to a green row. + // A UI colours by status, not by the prose after it. protected virtual string GetDataStatus(IList missingNames) { return missingNames.Count > 0 ? Statuses.Warning : Statuses.Passed; diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 75b9773..3300adf 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -26,7 +26,7 @@ public class AnalyticsService : IAnalyticsService // The probes are independent, so they run together instead of nose to tail. Capped rather than unbounded: // GA4 limits concurrent requests per property, and a criteria naming no events can carry MaxEventNames. private const int MaxProbeConcurrency = 4; - // The platform expresses Caching:CacheEnabled=false as a one-tick TTL on the default entry options. + // How the platform expresses Caching:CacheEnabled=false on the options it hands the factory. private static readonly TimeSpan CacheDisabled = TimeSpan.FromTicks(1); private const string SearchOperation = "events search"; @@ -118,7 +118,6 @@ protected virtual async Task GetOrCreateAsync( return createEmptyResult(); } - // Diagnostics asks for the live state, so it opts out rather than reading a cached verdict. if (criteria.BypassCache) { return await ReadAsync(operation, criteria, factory, createEmptyResult, settings, null); @@ -165,12 +164,12 @@ private async Task ReadAsync( } } - // The request carries dates, so two criteria differing only in time of day are the same Google query — - // while GetCacheKey() renders From/To at second precision and would miss the hit on a metered API. + // The request carries dates, so criteria differing only in time of day are one Google query — but + // GetCacheKey() renders From/To at second precision and would miss the hit on a metered API. protected virtual T WithNormalizedDates(T criteria) where T : AnalyticsEventCriteriaBase { - if (criteria.From?.TimeOfDay == TimeSpan.Zero && criteria.To?.TimeOfDay == TimeSpan.Zero) + if (IsMidnightOrAbsent(criteria.From) && IsMidnightOrAbsent(criteria.To)) { return criteria; } @@ -182,6 +181,11 @@ protected virtual T WithNormalizedDates(T criteria) return result; } + private static bool IsMidnightOrAbsent(DateTime? value) + { + return value is null || value.Value.TimeOfDay == TimeSpan.Zero; + } + // A summary is a sum and a newest-occurrence per event name, and GA has no "max(dateHour)" aggregation — so // reducing a fetched series here would transfer one row per event name PER HOUR (years of rows) to produce two // numbers. Two narrow reads answer it instead: 'count' mode collapses to one row per event name carrying the @@ -211,8 +215,7 @@ await Parallel.ForEachAsync( } catch (Exception ex) { - // The totals read already succeeded. Faulting the loop would discard every count and cache - // the zeros, so a failed probe costs its own last-occurrence and nothing else. + // The totals already succeeded; faulting the loop would discard every count and cache zeros. LogFailure($"last occurrence of '{summary.EventName}'", criteria.StoreId, ex); } }); @@ -248,8 +251,7 @@ protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settin return query; } - // Requested event names still yield zero-count summaries. A criteria naming NO names has nothing to shape - // them from, so that one does come back empty. + // Requested names still yield zero-count summaries; a criteria naming none has nothing to shape them from. protected virtual IList CreateEmptySummaries(AnalyticsEventSummaryCriteria criteria) { return CreateSummaries(criteria, []); @@ -294,9 +296,8 @@ protected virtual TimeSpan GetCacheTtl(AnalyticsDataApiSettings settings) return TimeSpan.FromMinutes(settings.CacheTtlMinutes); } - // Mirrors SalesRep's StatisticsCache.Apply: the platform hands the factory its DEFAULT entry options, so a - // TTL written straight over them makes Caching:CacheEnabled=false inert and leaves the platform's sliding - // default (15 min) to evict before the TTL this module documents as a setting. + // The factory is handed the platform's DEFAULT options: a TTL written straight over them makes + // CacheEnabled=false inert and leaves the 15-min sliding default to evict first. protected virtual void ApplyCacheTtl(MemoryCacheEntryOptions options, TimeSpan ttl) { if (options.AbsoluteExpirationRelativeToNow == CacheDisabled) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs index 70d2536..b9bb957 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs @@ -43,7 +43,7 @@ public virtual async Task ResolveAsync(string storeId) protected virtual async Task GetSettingAsync(ICollection storeSettings, SettingDescriptor descriptor) { // Not just null: the admin UI writes "" for a cleared ShortText, and taking that as an override would - // make clearing a store-level value disable reporting instead of restoring the global one. + // disable reporting rather than restore the global value. var hasStoreValue = storeSettings?.Any(x => x.Name.EqualsIgnoreCase(descriptor.Name) && x.Value is not null and not "") == true; @@ -59,7 +59,7 @@ protected virtual async Task GetSettingAsync(ICollection _logger; - public GoogleAnalyticsDataSource(IGoogleAnalyticsReportClient reportClient) + public GoogleAnalyticsDataSource( + IGoogleAnalyticsReportClient reportClient, + ILogger logger) { _reportClient = reportClient; + _logger = logger; } public virtual Task GetRowsAsync(AnalyticsDataQuery query) @@ -153,9 +158,8 @@ protected virtual FilterExpression BuildDimensionFilter(AnalyticsDataQuery query foreach (var filter in (query.DimensionFilters ?? []).Where(x => !string.IsNullOrEmpty(x.DimensionName))) { - // Dropping a valueless filter turns a scoping constraint into no constraint at all — and these carry - // the consumer's data isolation, so the read would silently widen to every organization. An empty - // list is the caller's bug, and this is the last place that can still refuse it. + // Dropped, this widens the read instead of narrowing it — and these filters carry the consumer's + // data isolation. Last place that can still refuse it. if (filter.Values.IsNullOrEmpty()) { throw new ArgumentException( @@ -246,9 +250,8 @@ protected static bool IsCountSort(AnalyticsDataQuery query) return ModuleConstants.SortBy.Count.EqualsIgnoreCase(query.SortBy); } - // GA4 reports dateHour in the PROPERTY's reporting timezone — the proto documents ResponseMetaData.time_zone - // as being there "to interpret time-based dimensions like hour and minute" — and it ships that zone with every - // response. Assuming UTC instead put every bucket out by the property's offset, silently. + // GA4 reports dateHour in the PROPERTY's timezone and ships that zone with every response; assuming UTC put + // every bucket out by the property's offset. protected virtual TimeZoneInfo ResolvePropertyTimeZone(string timeZoneId) { if (string.IsNullOrEmpty(timeZoneId)) @@ -256,9 +259,17 @@ protected virtual TimeZoneInfo ResolvePropertyTimeZone(string timeZoneId) return TimeZoneInfo.Utc; } - // GA gives an IANA id, which .NET resolves on every platform since ICU. An id it cannot resolve leaves - // the buckets as they were read rather than failing the whole report. - return TimeZoneInfo.TryFindSystemTimeZoneById(timeZoneId, out var timeZone) ? timeZone : TimeZoneInfo.Utc; + if (TimeZoneInfo.TryFindSystemTimeZoneById(timeZoneId, out var timeZone)) + { + return timeZone; + } + + // Falling back leaves every bucket out by the property's offset, so it cannot pass unrecorded. + _logger.LogWarning( + "Google Analytics reported time zone '{TimeZone}', which could not be resolved; hour buckets are left " + + "uncorrected and may be wrong by the property's offset", timeZoneId); + + return TimeZoneInfo.Utc; } private static DateTime? ParseDateHour(string value, TimeZoneInfo timeZone) @@ -270,8 +281,7 @@ protected virtual TimeZoneInfo ResolvePropertyTimeZone(string timeZoneId) var local = DateTime.SpecifyKind(result, DateTimeKind.Unspecified); - // The hour a spring-forward skips has no UTC instant; GA cannot bucket into it, but a mis-set property - // could, and converting one throws. + // The hour a spring-forward skips has no UTC instant, and converting one throws. return timeZone.IsInvalidTime(local) ? DateTime.SpecifyKind(local, DateTimeKind.Utc) : TimeZoneInfo.ConvertTimeToUtc(local, timeZone); diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs index b760758..19694e2 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs @@ -58,8 +58,7 @@ public async Task RunAsync_HappyPath_AllStagesPassInStableOrder() Assert.Contains("session_kind", GetCheck(result, Stages.CustomDimensions).Message); Assert.Contains("searchTerms", GetCheck(result, Stages.ReportCompatibility).Message); - // A happy path sees every event the request asks about, so nothing is "not seen" here — the two - // Requested*NotSeen tests own that case. + // A happy path sees every requested event; the two Requested*NotSeen tests own the other case. var realtime = GetCheck(result, Stages.Realtime); Assert.Contains("search=5", realtime.Message); Assert.Contains("view_item=1", realtime.Message); @@ -410,8 +409,7 @@ public async Task Realtime_HardFailure_FailsRealtimeOnlyProcessedStillRuns() Assert.Equal(Statuses.Passed, GetCheck(result, Stages.ProcessedData).Status); } - // Asserted as behaviour, not as a constructor signature: caching inside the report client would leave a - // signature check green while diagnostics reported a stale verdict, which is the failure that matters. + // Behaviour, not a constructor signature: caching inside the report client would leave that check green. [Fact] public async Task RunAsync_CalledTwiceWithTheSameArguments_ReadsGoogleEachTime() { @@ -443,8 +441,7 @@ private async Task RunApiAccessFailureAsync(RpcExcept return apiAccess; } - // A green row plus a sentence is not an answer: a UI colours by status, so an event the operator asked - // about and is not seeing has to move the status. + // A green row plus a sentence is not an answer: a UI colours by status. [Fact] public async Task Realtime_RequestedEventNotSeen_WarnsAndNamesIt() { @@ -457,7 +454,7 @@ public async Task Realtime_RequestedEventNotSeen_WarnsAndNamesIt() var realtime = GetCheck(result, Stages.Realtime); Assert.Equal(Statuses.Warning, realtime.Status); Assert.Contains("view_item", realtime.Message); - // The stage still reports what it DID see, and the run continues. + // The stage still reports what it DID see. Assert.Contains("search=2", realtime.Message); Assert.Equal(Statuses.Passed, GetCheck(result, Stages.ProcessedData).Status); } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index 6c7b4a9..20d082c 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; @@ -29,7 +30,7 @@ public class AnalyticsServiceTests public AnalyticsServiceTests() { - _googleDataSourceMock = new Mock(_reportClientMock.Object); + _googleDataSourceMock = new Mock(_reportClientMock.Object, NullLogger.Instance); } [Theory] @@ -218,8 +219,7 @@ public async Task GetEventSummariesAsync_AggregatesPerRequestedEventName() Assert.Null(signUp.LastOccurredAt); } - // The totals read has already succeeded by the time the probes run. Faulting the whole loop would throw - // those counts away and cache the zeros, so one bad probe would report "no activity" for every event. + // Faulting the whole loop would throw away counts that already succeeded, and cache the zeros. [Fact] public async Task GetEventSummariesAsync_OneProbeFails_KeepsTheTotalsAndOnlyLosesThatLastOccurrence() { @@ -345,8 +345,7 @@ private AnalyticsService CreateGoogleConfiguredService(TimeSpan? failureCacheTtl return CreateService(new AnalyticsDataApiSettings { PropertyId = "123456" }, failureCacheTtl, cacheEnabled); } - // The request only carries dates, so two criteria differing by time of day are the SAME Google query — - // and the key is what decides whether the metered API is called twice for it. + // The request only carries dates, so these are one Google query — and the key decides whether it runs twice. [Fact] public async Task SearchEventsAsync_CriteriaDifferingOnlyInTimeOfDay_UsesCache() { @@ -382,7 +381,7 @@ public async Task SearchEventsAsync_NormalizingDates_DoesNotMutateTheCallersCrit Assert.Equal(To.Date.AddHours(9).AddMinutes(17), criteria.To); } - // Diagnostics answers "is it working right now", so it must not be served a cached verdict. + // Diagnostics must not be served a cached verdict. [Fact] public async Task SearchEventsAsync_BypassCache_ReadsEveryTime() { @@ -400,7 +399,7 @@ public async Task SearchEventsAsync_BypassCache_ReadsEveryTime() _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } - // The platform expresses Caching:CacheEnabled=false as a one-tick TTL on the options it hands the factory. + // The platform expresses that switch as a one-tick TTL on the options it hands the factory. [Fact] public async Task SearchEventsAsync_PlatformCachingDisabled_ReadsEveryTime() { @@ -460,7 +459,7 @@ private static AnalyticsEventSummaryCriteria CreateSummaryCriteria(params string }; } - // Count mode collapses to one row per event name and carries no date; the probes are what fill it in. + // Count mode carries no date; the probes are what fill it in. private static AnalyticsEventSearchResult CreateCountModeResult(params (string EventName, int Count)[] events) { return new AnalyticsEventSearchResult diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs index 77fea0e..aef537e 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs @@ -65,8 +65,7 @@ public async Task ResolveAsync_FallsBackToGlobalSettings() Assert.Equal("global-property", settings.PropertyId); } - // Clearing a store-level override in the admin UI writes "", not nothing. Taking that as an override made - // clearing it DISABLE reporting instead of restoring the global property. + // The admin UI writes "" for a cleared override, not nothing. [Fact] public async Task ResolveAsync_ClearedStoreValue_FallsBackToGlobal() { diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs index 73b1e14..8b02c9c 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using Google.Analytics.Data.V1Beta; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; @@ -318,8 +319,7 @@ public async Task GetRowsAsync_ItemRows_MultipleEventNames_LeavesEventNameNull() Assert.Null(Assert.Single(result.Events).EventName); } - // GA4 reports dateHour in the PROPERTY's timezone and ships that zone with the response. Stamping the - // bucket UTC instead put every date on these screens out by the property's offset, silently. + // GA4 reports dateHour in the PROPERTY's timezone and ships that zone with the response. [Fact] public async Task GetRowsAsync_PropertyTimeZone_ConvertsTheHourBucketToUtc() { @@ -344,8 +344,7 @@ public async Task GetRowsAsync_NoUsableTimeZone_LeavesTheBucketAsRead(string tim Assert.Equal(new DateTime(2026, 8, 25, 10, 0, 0, DateTimeKind.Utc), Assert.Single(result.Events).OccurredAt); } - // A filter with no values used to be dropped, which silently widened the read to every organization — - // the one failure mode these filters exist to prevent. + // Dropped rather than refused, this widens the read to every organization. [Fact] public async Task GetRowsAsync_DimensionFilterWithoutValues_Throws() { @@ -398,6 +397,6 @@ private GoogleAnalyticsDataSource CreateDataSource(RunReportResponse response = .Callback((RunReportRequest request) => _capturedRequest = request) .ReturnsAsync(response ?? new RunReportResponse()); - return new GoogleAnalyticsDataSource(_reportClientMock.Object); + return new GoogleAnalyticsDataSource(_reportClientMock.Object, NullLogger.Instance); } } From 2712d8bb970348b841755be6181b53193d2976aa Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Wed, 16 Sep 2026 09:06:17 -0600 Subject: [PATCH 23/36] Refactoring (after alexeyshibanov review, round #2) --- README.md | 7 +- docs/index.md | 36 +++- .../Exceptions/AnalyticsException.cs | 20 ++ .../Models/AnalyticsDataApiSettings.cs | 4 +- .../Services/AnalyticsService.cs | 144 ++++++++------ .../Services/AnalyticsSettingsResolver.cs | 13 +- .../Services/GoogleAnalyticsDataSource.cs | 23 ++- .../AnalyticsServiceTests.cs | 178 +++++++++++++----- .../AnalyticsSettingsResolverTests.cs | 26 +++ .../GoogleAnalyticsDataSourceTests.cs | 40 ++-- 10 files changed, 363 insertions(+), 128 deletions(-) create mode 100644 src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Exceptions/AnalyticsException.cs diff --git a/README.md b/README.md index 38f5c2d..b11d99a 100644 --- a/README.md +++ b/README.md @@ -124,9 +124,14 @@ Reporting needs two extra store settings — the numeric **GA4 property id** and deliberately no credential *setting*, so no key material is stored in the platform database. `POST api/googleanalytics/{storeId}/diagnostics` runs a staged checklist that names exactly what is missing. +Reads **throw** rather than answer a failure with an empty result — a store with no property id, a refused +credential and a Google outage all raise `AnalyticsException` — so a consumer can tell "reporting is broken" from +"this customer did nothing" and decide for itself how to degrade. + Two limits are properties of the source rather than of this module: GA4 takes up to **24-48 hours** to process events, and reports are **aggregates** whose finest time dimension is the hour. Setup steps, the settings, the -credential prerequisites and the diagnostics payload are documented in [docs/index.md](docs/index.md). +credential prerequisites, the failure contract and the diagnostics payload are documented in +[docs/index.md](docs/index.md). ## Documentation * [Google Analytics 4](https://developers.google.com/analytics/devguides/collection/ga4) diff --git a/docs/index.md b/docs/index.md index a7edf62..da71758 100644 --- a/docs/index.md +++ b/docs/index.md @@ -78,10 +78,13 @@ needed if another module consumes `IAnalyticsService`. Settings > Property Details), e.g. `123456789`. This is *not* the `G-XXXXXXXXXX` measurement id. 1. **GoogleAnalytics4.DataApi.CacheTtlMinutes** - how long a successful report is cached per store and query (default `60`). Data API tokens are metered per property per day, so caching is a quota requirement rather than - tuning; failed reports are cached for a fixed 60 seconds so a misconfiguration cannot burn quota. Set it to - **`0`** to read Google on every call, and note that the platform's own `Caching:CacheEnabled=false` is honoured - here too - both are for diagnosing stale reporting data, not for normal operation. The cache key is the store - plus the query's *dates*, so two reads differing only in time of day share one entry. + tuning; a failed report is cached for a fixed 60 seconds - as the failure it was, not as an empty result - so a + misconfiguration cannot burn quota. Set it to **`0`** to read Google on every call, and note that the platform's + own `Caching:CacheEnabled=false` is honoured here too - both are for diagnosing stale reporting data, not for + normal operation. The cache key covers the **whole** query - store, event names, dimensions, filters, sort and + paging as well as dates - together with the resolved property id, so re-pointing a store at another property + takes effect at once; `from`/`to` are rounded to the day, so two reads differing only in time of day share one + entry. ## Reading analytics data @@ -108,6 +111,31 @@ consumers build their own fields on top of it. 1. Register any **user-scoped custom dimensions** a consumer filters on in GA4 Admin > Custom definitions. Registration is **not retroactive** — only events collected after it are reportable. +### When a read fails + +`IAnalyticsService` reads **throw**; they never answer a failure with an empty result, because an empty result is +an answer a consumer will act on and "no data" would become the module's single reply to every question. A read +checks three things in order — the criteria, then the store's configuration, then Google — and each of them can +refuse: + +* a criteria with no store id, a dimension filter with no name or no values, or an item-scoped read narrowed to + more than one event name — `ArgumentException`, raised before anything is loaded or cached; +* a store with no property id — `AnalyticsException`. Not being configured is a configuration error, not a state + to be reported as "no activity"; +* anything Google refuses — a property that does not exist, a credential without access, a quota rejection — + `AnalyticsException`. + +The exception names the store and the operation and nothing else. The property id, the settings and Google's own +response stay in this module's log, because a consumer cannot know how far its own error surface travels; run +`POST api/googleanalytics/{storeId}/diagnostics` to see the cause. + +Only the third step is cached. The criteria and the configuration are re-checked on every call, so a store +configured a minute after a failed read reports at once instead of after the TTL. `IsConfiguredAsync` is a +question rather than a read, and answers `false` instead of throwing. + +How to degrade is the consumer's decision: catch `AnalyticsException`, show your own unavailable state, and keep +"reporting is broken" distinguishable from "this customer did nothing". + ### What this source can and cannot answer * GA4 processes events for up to **24-48 hours** before `runReport` can see them. "No rows" right after tagging is diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Exceptions/AnalyticsException.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Exceptions/AnalyticsException.cs new file mode 100644 index 0000000..39c8d8f --- /dev/null +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Exceptions/AnalyticsException.cs @@ -0,0 +1,20 @@ +using System; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Exceptions; + +// Every IAnalyticsService read failure arrives as this — a store with no property id, a refused credential, a +// Google outage. The message names the store and the operation and nothing else: the property id, the settings +// and Google's own response stay in this module's log, because a consumer surfacing the failure has no way to +// know how far its own error surface travels. +public class AnalyticsException : Exception +{ + public AnalyticsException(string message) + : base(message) + { + } + + public AnalyticsException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs index 61ee926..51ea984 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs @@ -9,5 +9,7 @@ public class AnalyticsDataApiSettings // The descriptor's default: 0 now means "disabled", so an unset instance must not land on it. public int CacheTtlMinutes { get; set; } = DataApiSettings.DefaultCacheTtlMinutes; - public bool IsConfigured => !string.IsNullOrEmpty(PropertyId); + // Whitespace is not a property id: a value of spaces would otherwise report as configured and build + // "properties/ " for every read. + public bool IsConfigured => !string.IsNullOrWhiteSpace(PropertyId); } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 3300adf..8d9cd28 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Exceptions; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; @@ -51,6 +52,8 @@ public AnalyticsService( protected virtual TimeSpan FailureCacheTtl => TimeSpan.FromSeconds(60); + // A question, not a read: the caller is asking whether reading would work at all, so a store that cannot + // report is the answer rather than a failure. public virtual async Task IsConfiguredAsync(string storeId) { try @@ -67,9 +70,7 @@ public virtual async Task IsConfiguredAsync(string storeId) public virtual async Task SearchEventsAsync(AnalyticsEventSearchCriteria criteria) { - ArgumentNullException.ThrowIfNull(criteria); - - criteria = WithNormalizedDates(criteria); + criteria = PrepareCriteria(criteria); var result = await GetOrCreateAsync( SearchOperation, @@ -83,87 +84,125 @@ public virtual async Task SearchEventsAsync(Analytic query.Skip = criteria.Skip; return _dataSource.GetRowsAsync(query); - }, - AbstractTypeFactory.TryCreateInstance); + }); return result.CloneTyped(); } public virtual async Task> GetEventSummariesAsync(AnalyticsEventSummaryCriteria criteria) { - ArgumentNullException.ThrowIfNull(criteria); - - criteria = WithNormalizedDates(criteria); + criteria = PrepareCriteria(criteria); var result = await GetOrCreateAsync( SummariesOperation, criteria, - settings => CreateSummariesAsync(settings, criteria), - () => CreateEmptySummaries(criteria)); + settings => CreateSummariesAsync(settings, criteria)); return result.Select(x => x.CloneTyped()).ToList(); } + // Step 1 of a Google call: the arguments. Nothing here loads configuration or reaches the cache, so a caller + // that fixes its criteria is answered at once rather than after a TTL. + protected virtual T PrepareCriteria(T criteria) + where T : AnalyticsEventCriteriaBase + { + ArgumentNullException.ThrowIfNull(criteria); + + if (string.IsNullOrWhiteSpace(criteria.StoreId)) + { + throw new ArgumentException("A store id is required: it is what selects the GA4 property to report on.", nameof(criteria)); + } + + return WithNormalizedDates(criteria); + } + protected virtual async Task GetOrCreateAsync( string operation, AnalyticsEventCriteriaBase criteria, - Func> factory, - Func createEmptyResult) + Func> factory) + where T : class { - try + var settings = await ResolveSettingsAsync(operation, criteria.StoreId); + + T result; + + if (criteria.BypassCache) { - var settings = await _settingsResolver.ResolveAsync(criteria.StoreId); - if (!settings.IsConfigured) - { - return createEmptyResult(); - } + result = await ReadAsync(operation, criteria.StoreId, factory, settings, cacheOptions: null); + } + else + { + // The property id belongs in the key because it is what the rows are OF: re-pointing a store at + // another property must not keep serving the previous property's numbers until the TTL runs out. + var cacheKey = CacheKey.With(GetType(), operation, settings.PropertyId, criteria.GetCacheKey()); - if (criteria.BypassCache) - { - return await ReadAsync(operation, criteria, factory, createEmptyResult, settings, null); - } + result = await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, + cacheOptions => ReadAsync(operation, criteria.StoreId, factory, settings, cacheOptions)); + } - var cacheKey = CacheKey.With(GetType(), operation, criteria.GetCacheKey()); + return result ?? throw CreateReadException(operation, criteria.StoreId); + } - return await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, cacheOptions => - { - ApplyCacheTtl(cacheOptions, GetCacheTtl(settings)); + // Step 2: the configuration. Cheap, calls nothing, and deliberately outside the cache — a cached "not + // configured" would outlive the setting that fixed it. + protected virtual async Task ResolveSettingsAsync(string operation, string storeId) + { + AnalyticsDataApiSettings settings; - return ReadAsync(operation, criteria, factory, createEmptyResult, settings, cacheOptions); - }); + try + { + settings = await _settingsResolver.ResolveAsync(storeId); } catch (Exception ex) { - LogFailure(operation, criteria.StoreId, ex); - return createEmptyResult(); + LogFailure(operation, storeId, ex); + throw CreateReadException(operation, storeId); + } + + if (!settings.IsConfigured) + { + // Not a state to answer with an empty result: a store with no property id cannot report at all, and + // a consumer handed "no data" would present that as fact. + throw new AnalyticsException($"Google Analytics reporting is not configured for store '{storeId}'."); } + + return settings; } - private async Task ReadAsync( + // Step 3: the call, and the only step that is cached. A failed call is cached as a null — the entry is what + // keeps a property Google refuses from spending the quota again on every page render — and every reader of + // it is told the read failed rather than handed an empty result. + protected virtual async Task ReadAsync( string operation, - AnalyticsEventCriteriaBase criteria, + string storeId, Func> factory, - Func createEmptyResult, AnalyticsDataApiSettings settings, MemoryCacheEntryOptions cacheOptions) + where T : class { try { - return await factory(settings); + var result = await factory(settings); + ApplyCacheTtl(cacheOptions, GetCacheTtl(settings)); + + return result; } catch (Exception ex) { - LogFailure(operation, criteria.StoreId, ex); - - if (cacheOptions != null) - { - ApplyCacheTtl(cacheOptions, FailureCacheTtl); - } + LogFailure(operation, storeId, ex); + ApplyCacheTtl(cacheOptions, FailureCacheTtl); - return createEmptyResult(); + return null; } } + // Google's own words never leave this module: what a consumer catches names the store and the operation, + // because it cannot know how far its own error surface travels. The cause is in the log line above. + protected virtual AnalyticsException CreateReadException(string operation, string storeId) + { + return new AnalyticsException($"Google Analytics {operation} failed for store '{storeId}'."); + } + // The request carries dates, so criteria differing only in time of day are one Google query — but // GetCacheKey() renders From/To at second precision and would miss the hit on a metered API. protected virtual T WithNormalizedDates(T criteria) @@ -204,21 +243,12 @@ protected virtual async Task> CreateSummariesAsync( // Count-mode rows carry no date, so the summaries come back with a null LastOccurredAt that the probe fills. var summaries = CreateSummaries(criteria, totals.Events); + // A failed probe fails the whole summary read: its null LastOccurredAt is indistinguishable from "this + // event has never happened", so keeping the totals would hand a consumer a wrong answer as a fact. await Parallel.ForEachAsync( summaries.Where(x => x.TotalCount > 0), new ParallelOptions { MaxDegreeOfParallelism = MaxProbeConcurrency }, - async (summary, _) => - { - try - { - summary.LastOccurredAt = await GetLastOccurredAtAsync(settings, criteria, summary.EventName); - } - catch (Exception ex) - { - // The totals already succeeded; faulting the loop would discard every count and cache zeros. - LogFailure($"last occurrence of '{summary.EventName}'", criteria.StoreId, ex); - } - }); + async (summary, _) => summary.LastOccurredAt = await GetLastOccurredAtAsync(settings, criteria, summary.EventName)); return summaries; } @@ -251,12 +281,6 @@ protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settin return query; } - // Requested names still yield zero-count summaries; a criteria naming none has nothing to shape them from. - protected virtual IList CreateEmptySummaries(AnalyticsEventSummaryCriteria criteria) - { - return CreateSummaries(criteria, []); - } - protected virtual IList CreateSummaries(AnalyticsEventSummaryCriteria criteria, IList events) { var aggregates = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -300,7 +324,7 @@ protected virtual TimeSpan GetCacheTtl(AnalyticsDataApiSettings settings) // CacheEnabled=false inert and leaves the 15-min sliding default to evict first. protected virtual void ApplyCacheTtl(MemoryCacheEntryOptions options, TimeSpan ttl) { - if (options.AbsoluteExpirationRelativeToNow == CacheDisabled) + if (options == null || options.AbsoluteExpirationRelativeToNow == CacheDisabled) { return; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs index b9bb957..56bd9d0 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs @@ -42,11 +42,8 @@ public virtual async Task ResolveAsync(string storeId) protected virtual async Task GetSettingAsync(ICollection storeSettings, SettingDescriptor descriptor) { - // Not just null: the admin UI writes "" for a cleared ShortText, and taking that as an override would - // disable reporting rather than restore the global value. var hasStoreValue = storeSettings?.Any(x => - x.Name.EqualsIgnoreCase(descriptor.Name) && - x.Value is not null and not "") == true; + x.Name.EqualsIgnoreCase(descriptor.Name) && IsStoreOverride(x.Value)) == true; if (!hasStoreValue) { @@ -66,4 +63,12 @@ protected virtual async Task GetSettingAsync(ICollection(descriptor); } } + + // Not just null: the admin UI writes "" for a cleared ShortText and a value of spaces is the same gesture, + // so taking either as an override would disable reporting rather than restore the global value. Non-text + // settings keep the plain null check — 0 is a legitimate CacheTtlMinutes. + private static bool IsStoreOverride(object value) + { + return value is string text ? !string.IsNullOrWhiteSpace(text) : value is not null; + } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs index 4c0ff50..ba674d9 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs @@ -53,6 +53,16 @@ protected virtual async Task GetEventScopedRowsAsync // kept as a separate path so it can be reworked without touching the main query path. protected virtual async Task GetItemScopedRowsAsync(AnalyticsDataQuery query) { + // The item report carries no eventName dimension, so every row arrives with a null EventName and only a + // single requested name can be filled back in below. Two or more would reach a consumer as rows it cannot + // attribute — and through CreateSummaries as zero counts indistinguishable from no activity at all. + if (query.EventNames?.Count > 1) + { + throw new ArgumentException( + "An item-scoped read cannot be narrowed to more than one event name: its rows carry no event name to tell them apart.", + nameof(query)); + } + var response = await _reportClient.RunReportAsync(BuildItemReportRequest(query)); var result = MapResponse(response, query); @@ -156,10 +166,17 @@ protected virtual FilterExpression BuildDimensionFilter(AnalyticsDataQuery query expressions.Add(AnalyticsFilterBuilder.CreateInListExpression(ModuleConstants.Dimensions.EventName, query.EventNames)); } - foreach (var filter in (query.DimensionFilters ?? []).Where(x => !string.IsNullOrEmpty(x.DimensionName))) + foreach (var filter in query.DimensionFilters ?? []) { - // Dropped, this widens the read instead of narrowing it — and these filters carry the consumer's - // data isolation. Last place that can still refuse it. + // Dropped rather than refused, either of these widens the read instead of narrowing it — and these + // filters carry the consumer's data isolation. Last place that can still refuse one. + if (string.IsNullOrWhiteSpace(filter?.DimensionName)) + { + throw new ArgumentException( + "A dimension filter carries no dimension name, which would leave the read unscoped.", + nameof(query)); + } + if (filter.Values.IsNullOrEmpty()) { throw new ArgumentException( diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index 20d082c..0046fd5 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; using Moq; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Exceptions; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; @@ -21,6 +22,7 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; public class AnalyticsServiceTests { private const string StoreId = "test-store"; + private const string PropertyId = "123456"; private static readonly DateTime To = new(2026, 8, 25, 12, 0, 0, DateTimeKind.Utc); @@ -34,8 +36,9 @@ public AnalyticsServiceTests() } [Theory] - [InlineData("123456", true)] + [InlineData(PropertyId, true)] [InlineData("", false)] + [InlineData(" ", false)] [InlineData(null, false)] public async Task IsConfiguredAsync_Matrix(string propertyId, bool expected) { @@ -55,18 +58,54 @@ public async Task IsConfiguredAsync_ResolverThrows_ReturnsFalse() Assert.False(await service.IsConfiguredAsync(StoreId)); } + // Step 1 of a Google call: the arguments, before anything loads configuration. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task SearchEventsAsync_WithoutStoreId_ThrowsBeforeResolvingSettings(string storeId) + { + var service = CreateService(); + + var criteria = CreateSearchCriteria(); + criteria.StoreId = storeId; + + await Assert.ThrowsAsync(() => service.SearchEventsAsync(criteria)); + _settingsResolverMock.Verify(x => x.ResolveAsync(It.IsAny()), Times.Never); + } + + // "Not configured" is a configuration error, not an answer: a consumer handed an empty result would present + // "no activity" as fact. [Fact] - public async Task SearchEventsAsync_NotConfigured_ReturnsEmptyWithoutQueryingSource() + public async Task SearchEventsAsync_NotConfigured_ThrowsWithoutQueryingSource() { var service = CreateService(new AnalyticsDataApiSettings()); - var result = await service.SearchEventsAsync(CreateSearchCriteria()); + await Assert.ThrowsAsync(() => service.SearchEventsAsync(CreateSearchCriteria())); - Assert.Equal(0, result.TotalCount); - Assert.Empty(result.Events); _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); } + // Only the Google call is cached. Configuration is re-read on every call, so the store configured a minute + // after a failed read reports at once instead of after the TTL. + [Fact] + public async Task SearchEventsAsync_ConfiguredAfterAFailedRead_ReportsWithoutWaitingForTheTtl() + { + _settingsResolverMock + .SetupSequence(x => x.ResolveAsync(StoreId)) + .ReturnsAsync(new AnalyticsDataApiSettings()) + .ReturnsAsync(new AnalyticsDataApiSettings { PropertyId = PropertyId }); + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateService(); + + await Assert.ThrowsAsync(() => service.SearchEventsAsync(CreateSearchCriteria())); + var result = await service.SearchEventsAsync(CreateSearchCriteria()); + + Assert.Equal(1, result.TotalCount); + } + [Fact] public async Task SearchEventsAsync_SameCriteria_UsesCache() { @@ -113,6 +152,26 @@ public async Task SearchEventsAsync_DifferentDimensionFilterValues_UseDifferentC _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } + // The rows are OF a property: re-pointing the store must not keep serving the previous property's numbers. + [Fact] + public async Task SearchEventsAsync_PropertyIdChanged_DoesNotServeThePreviousPropertysRows() + { + _settingsResolverMock + .SetupSequence(x => x.ResolveAsync(StoreId)) + .ReturnsAsync(new AnalyticsDataApiSettings { PropertyId = "111111" }) + .ReturnsAsync(new AnalyticsDataApiSettings { PropertyId = "222222" }); + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateService(); + + await service.SearchEventsAsync(CreateSearchCriteria()); + await service.SearchEventsAsync(CreateSearchCriteria()); + + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.Is(q => q.PropertyId == "111111")), Times.Once); + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.Is(q => q.PropertyId == "222222")), Times.Once); + } + [Fact] public async Task SearchEventsAsync_ReturnsClonedResult() { @@ -146,26 +205,29 @@ public async Task SearchEventsAsync_PropagatesCriteriaToQuery() Assert.Equal(ModuleConstants.SortBy.Count, capturedQuery.SortBy); Assert.Equal(criteria.DimensionNames, capturedQuery.DimensionNames); - Assert.Equal("123456", capturedQuery.PropertyId); + Assert.Equal(PropertyId, capturedQuery.PropertyId); Assert.Equal(20, capturedQuery.Take); Assert.Equal(5, capturedQuery.Skip); } + // The failed call is cached, not the empty result it used to return: the entry protects the quota, and every + // reader of it is still told the read failed. [Fact] - public async Task SearchEventsAsync_SourceFails_CachesEmptyResultForFailureTtl() + public async Task SearchEventsAsync_SourceFails_ThrowsAndCachesTheFailure() { _googleDataSourceMock .SetupSequence(x => x.GetRowsAsync(It.IsAny())) - .ThrowsAsync(new InvalidOperationException("GA responded 400")) + .ThrowsAsync(new InvalidOperationException($"PermissionDenied on property {PropertyId}")) .ReturnsAsync(CreateSearchResult(("search", To, 3))); var service = CreateGoogleConfiguredService(); - var failed = await service.SearchEventsAsync(CreateSearchCriteria()); - var cached = await service.SearchEventsAsync(CreateSearchCriteria()); + var failure = await Assert.ThrowsAsync(() => service.SearchEventsAsync(CreateSearchCriteria())); + await Assert.ThrowsAsync(() => service.SearchEventsAsync(CreateSearchCriteria())); - Assert.Equal(0, failed.TotalCount); - Assert.Empty(failed.Events); - Assert.Equal(0, cached.TotalCount); + // What a consumer catches names the store and the operation; Google's own words stay in the log. + Assert.Contains(StoreId, failure.Message); + Assert.DoesNotContain(PropertyId, failure.Message); + Assert.Null(failure.InnerException); _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); } @@ -178,15 +240,33 @@ public async Task SearchEventsAsync_FailureCacheExpires_RecoversQuickly() .ReturnsAsync(CreateSearchResult(("search", To, 3))); var service = CreateGoogleConfiguredService(failureCacheTtl: TimeSpan.FromMilliseconds(100)); - var failed = await service.SearchEventsAsync(CreateSearchCriteria()); + await Assert.ThrowsAsync(() => service.SearchEventsAsync(CreateSearchCriteria())); await Task.Delay(500, TestContext.Current.CancellationToken); var recovered = await service.SearchEventsAsync(CreateSearchCriteria()); - Assert.Equal(0, failed.TotalCount); Assert.Equal(1, recovered.TotalCount); _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } + // Left at the platform's sliding default, the entry evicts on the sliding clock and CacheTtlMinutes means + // something other than what it says. + [Fact] + public async Task SearchEventsAsync_ShorterSlidingDefault_DoesNotEvictBeforeTheConfiguredTtl() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateService( + new AnalyticsDataApiSettings { PropertyId = PropertyId, CacheTtlMinutes = 5 }, + slidingExpiration: TimeSpan.FromMilliseconds(50)); + + await service.SearchEventsAsync(CreateSearchCriteria()); + await Task.Delay(300, TestContext.Current.CancellationToken); + await service.SearchEventsAsync(CreateSearchCriteria()); + + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); + } + [Fact] public async Task GetEventSummariesAsync_AggregatesPerRequestedEventName() { @@ -219,9 +299,9 @@ public async Task GetEventSummariesAsync_AggregatesPerRequestedEventName() Assert.Null(signUp.LastOccurredAt); } - // Faulting the whole loop would throw away counts that already succeeded, and cache the zeros. + // Keeping the totals would answer "when did this last happen?" with a null that reads as "never". [Fact] - public async Task GetEventSummariesAsync_OneProbeFails_KeepsTheTotalsAndOnlyLosesThatLastOccurrence() + public async Task GetEventSummariesAsync_OneProbeFails_ThrowsRatherThanReportNever() { var totals = CreateCountModeResult(("search", 3), ("login", 5)); @@ -239,16 +319,8 @@ public async Task GetEventSummariesAsync_OneProbeFails_KeepsTheTotalsAndOnlyLose var service = CreateGoogleConfiguredService(); - var summaries = await service.GetEventSummariesAsync( - CreateSummaryCriteria(ModuleConstants.EventNames.Search, ModuleConstants.EventNames.Login)); - - var search = summaries.First(x => x.EventName == ModuleConstants.EventNames.Search); - Assert.Equal(3, search.TotalCount); - Assert.Equal(To, search.LastOccurredAt); - - var login = summaries.First(x => x.EventName == ModuleConstants.EventNames.Login); - Assert.Equal(5, login.TotalCount); - Assert.Null(login.LastOccurredAt); + await Assert.ThrowsAsync(() => service.GetEventSummariesAsync( + CreateSummaryCriteria(ModuleConstants.EventNames.Search, ModuleConstants.EventNames.Login))); } [Fact] @@ -294,35 +366,37 @@ public async Task GetEventSummariesAsync_ReadsNarrowly_NeverTheWholeHourlySeries } [Fact] - public async Task GetEventSummariesAsync_NotConfigured_ReturnsZeroSummariesPerRequestedName() + public async Task GetEventSummariesAsync_NotConfigured_Throws() { var service = CreateService(new AnalyticsDataApiSettings()); - var summaries = await service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Login)); + await Assert.ThrowsAsync(() => + service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Login))); - var summary = Assert.Single(summaries); - Assert.Equal(ModuleConstants.EventNames.Login, summary.EventName); - Assert.Equal(0, summary.TotalCount); - Assert.Null(summary.LastOccurredAt); + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); } [Fact] - public async Task GetEventSummariesAsync_SourceFails_CachesZeroSummariesForFailureTtl() + public async Task GetEventSummariesAsync_SourceFails_ThrowsAndCachesTheFailure() { _googleDataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) .ThrowsAsync(new InvalidOperationException("GA responded 400")); var service = CreateGoogleConfiguredService(); - var failed = await service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Login)); - var cached = await service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Login)); + await Assert.ThrowsAsync(() => + service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Login))); + await Assert.ThrowsAsync(() => + service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Login))); - Assert.Equal(0, Assert.Single(failed).TotalCount); - Assert.Equal(0, Assert.Single(cached).TotalCount); _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); } - private AnalyticsService CreateService(AnalyticsDataApiSettings settings = null, TimeSpan? failureCacheTtl = null, bool cacheEnabled = true) + private AnalyticsService CreateService( + AnalyticsDataApiSettings settings = null, + TimeSpan? failureCacheTtl = null, + bool cacheEnabled = true, + TimeSpan? slidingExpiration = null) { if (settings != null) { @@ -331,8 +405,14 @@ private AnalyticsService CreateService(AnalyticsDataApiSettings settings = null, .ReturnsAsync(settings); } + var cachingOptions = new CachingOptions { CacheEnabled = cacheEnabled }; + if (slidingExpiration != null) + { + cachingOptions.CacheSlidingExpiration = slidingExpiration; + } + var memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); - var platformMemoryCache = new PlatformMemoryCache(memoryCache, Options.Create(new CachingOptions { CacheEnabled = cacheEnabled }), new Mock>().Object); + var platformMemoryCache = new PlatformMemoryCache(memoryCache, Options.Create(cachingOptions), new Mock>().Object); var logger = new Mock>().Object; return failureCacheTtl == null @@ -342,7 +422,7 @@ private AnalyticsService CreateService(AnalyticsDataApiSettings settings = null, private AnalyticsService CreateGoogleConfiguredService(TimeSpan? failureCacheTtl = null, bool cacheEnabled = true) { - return CreateService(new AnalyticsDataApiSettings { PropertyId = "123456" }, failureCacheTtl, cacheEnabled); + return CreateService(new AnalyticsDataApiSettings { PropertyId = PropertyId }, failureCacheTtl, cacheEnabled); } // The request only carries dates, so these are one Google query — and the key decides whether it runs twice. @@ -399,6 +479,20 @@ public async Task SearchEventsAsync_BypassCache_ReadsEveryTime() _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } + [Fact] + public async Task SearchEventsAsync_BypassCache_SourceFails_Throws() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("GA responded 400")); + var service = CreateGoogleConfiguredService(); + + var criteria = CreateSearchCriteria(); + criteria.BypassCache = true; + + await Assert.ThrowsAsync(() => service.SearchEventsAsync(criteria)); + } + // The platform expresses that switch as a one-tick TTL on the options it hands the factory. [Fact] public async Task SearchEventsAsync_PlatformCachingDisabled_ReadsEveryTime() @@ -420,7 +514,7 @@ public async Task SearchEventsAsync_CacheTtlZero_ReadsEveryTime() _googleDataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) .ReturnsAsync(CreateSearchResult(("search", To, 3))); - var service = CreateService(new AnalyticsDataApiSettings { PropertyId = "123456", CacheTtlMinutes = 0 }); + var service = CreateService(new AnalyticsDataApiSettings { PropertyId = PropertyId, CacheTtlMinutes = 0 }); await service.SearchEventsAsync(CreateSearchCriteria()); await service.SearchEventsAsync(CreateSearchCriteria()); diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs index aef537e..5aa90e9 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs @@ -79,6 +79,32 @@ public async Task ResolveAsync_ClearedStoreValue_FallsBackToGlobal() Assert.True(settings.IsConfigured); } + // A property id of spaces is the same gesture as a cleared one — and it would otherwise report as configured + // and build "properties/ " for every read. + [Fact] + public async Task ResolveAsync_WhitespaceStoreValue_FallsBackToGlobal() + { + SetupGlobalSetting(ModuleConstants.Settings.DataApi.PropertyId.Name, "global-property"); + SetupStore(CreateStore((ModuleConstants.Settings.DataApi.PropertyId.Name, " "))); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(StoreId); + + Assert.Equal("global-property", settings.PropertyId); + } + + // Typed into the admin UI, not through it: a non-numeric override must not take reporting down with it. + [Fact] + public async Task ResolveAsync_MalformedStoreValue_FallsBackToGlobal() + { + SetupStore(CreateStore((ModuleConstants.Settings.DataApi.CacheTtlMinutes.Name, "not-a-number"))); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(StoreId); + + Assert.Equal(ModuleConstants.Settings.DataApi.DefaultCacheTtlMinutes, settings.CacheTtlMinutes); + } + [Fact] public async Task ResolveAsync_NullStoreId_UsesGlobalSettingsWithoutLoadingStore() { diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs index 8b02c9c..30243e7 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs @@ -295,28 +295,23 @@ public async Task GetRowsAsync_ItemRows_SingleEventName_FillsEventNameFromCriter Assert.Equal("SKU-001", analyticsEvent.Dimensions["itemId"]); } + // Item rows carry no event name, so two requested names would come back unattributable — and through + // CreateSummaries as zero counts indistinguishable from no activity. [Fact] - public async Task GetRowsAsync_ItemRows_MultipleEventNames_LeavesEventNameNull() + public async Task GetRowsAsync_ItemRows_MultipleEventNames_Throws() { - var response = new RunReportResponse { RowCount = 1 }; - response.DimensionHeaders.Add(new DimensionHeader { Name = "itemId" }); - - var row = new Row(); - row.DimensionValues.Add(new DimensionValue { Value = "SKU-001" }); - row.MetricValues.Add(new MetricValue { Value = "9" }); - response.Rows.Add(row); - - var dataSource = CreateDataSource(response); + var dataSource = CreateDataSource(); - var result = await dataSource.GetRowsAsync(new AnalyticsDataQuery + var query = new AnalyticsDataQuery { PropertyId = "123456", EventNames = new List { ModuleConstants.EventNames.ViewItem, ModuleConstants.EventNames.AddToCart }, DimensionNames = new List { ModuleConstants.Dimensions.ItemId }, Take = 10, - }); + }; - Assert.Null(Assert.Single(result.Events).EventName); + await Assert.ThrowsAsync(() => dataSource.GetRowsAsync(query)); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny()), Times.Never); } // GA4 reports dateHour in the PROPERTY's timezone and ships that zone with the response. @@ -361,6 +356,25 @@ public async Task GetRowsAsync_DimensionFilterWithoutValues_Throws() _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny()), Times.Never); } + // Same widening as a filter without values, from the other end. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetRowsAsync_DimensionFilterWithoutName_Throws(string dimensionName) + { + var dataSource = CreateDataSource(); + + var query = CreateDateHourQuery(); + query.DimensionFilters = new List + { + new() { DimensionName = dimensionName, Values = new List { "org1" } }, + }; + + await Assert.ThrowsAsync(() => dataSource.GetRowsAsync(query)); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny()), Times.Never); + } + private static AnalyticsDataQuery CreateDateHourQuery() { return new AnalyticsDataQuery From 632e06540572fd1cb3c3624722e81f222db97a78 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Wed, 16 Sep 2026 10:22:36 -0600 Subject: [PATCH 24/36] Empty StoreId handling --- docs/index.md | 9 ++-- .../Services/AnalyticsService.cs | 22 +++++---- .../AnalyticsServiceTests.cs | 49 +++++++++++++++++-- 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/docs/index.md b/docs/index.md index da71758..9af469f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -118,10 +118,11 @@ an answer a consumer will act on and "no data" would become the module's single checks three things in order — the criteria, then the store's configuration, then Google — and each of them can refuse: -* a criteria with no store id, a dimension filter with no name or no values, or an item-scoped read narrowed to - more than one event name — `ArgumentException`, raised before anything is loaded or cached; -* a store with no property id — `AnalyticsException`. Not being configured is a configuration error, not a state - to be reported as "no activity"; +* a dimension filter with no name or no values, or an item-scoped read narrowed to more than one event name — + `ArgumentException`, raised before anything is loaded or cached. A criteria with **no store id is not an error**: + it resolves the global settings, which is the documented store → global → default fallback; +* no property id configured, for the store or globally — `AnalyticsException`. Not being configured is a + configuration error, not a state to be reported as "no activity"; * anything Google refuses — a property that does not exist, a credential without access, a quota rejection — `AnalyticsException`. diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 8d9cd28..e7f4ddf 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -103,16 +103,14 @@ public virtual async Task> GetEventSummariesAsync(A // Step 1 of a Google call: the arguments. Nothing here loads configuration or reaches the cache, so a caller // that fixes its criteria is answered at once rather than after a TTL. + // + // StoreId is deliberately NOT required: an absent one resolves the global settings, which is the documented + // store -> global -> default fallback and the shape a consumer uses for an unnarrowed read. protected virtual T PrepareCriteria(T criteria) where T : AnalyticsEventCriteriaBase { ArgumentNullException.ThrowIfNull(criteria); - if (string.IsNullOrWhiteSpace(criteria.StoreId)) - { - throw new ArgumentException("A store id is required: it is what selects the GA4 property to report on.", nameof(criteria)); - } - return WithNormalizedDates(criteria); } @@ -161,9 +159,9 @@ protected virtual async Task ResolveSettingsAsync(stri if (!settings.IsConfigured) { - // Not a state to answer with an empty result: a store with no property id cannot report at all, and - // a consumer handed "no data" would present that as fact. - throw new AnalyticsException($"Google Analytics reporting is not configured for store '{storeId}'."); + // Not a state to answer with an empty result: no property id means nothing can be reported at all, + // and a consumer handed "no data" would present that as fact. + throw new AnalyticsException($"Google Analytics reporting is not configured{DescribeStore(storeId)}."); } return settings; @@ -200,7 +198,13 @@ protected virtual async Task ReadAsync( // because it cannot know how far its own error surface travels. The cause is in the log line above. protected virtual AnalyticsException CreateReadException(string operation, string storeId) { - return new AnalyticsException($"Google Analytics {operation} failed for store '{storeId}'."); + return new AnalyticsException($"Google Analytics {operation} failed{DescribeStore(storeId)}."); + } + + // An absent store id is a legitimate unnarrowed read, not a missing value, so it must not read as "store ''". + private static string DescribeStore(string storeId) + { + return string.IsNullOrEmpty(storeId) ? string.Empty : $" for store '{storeId}'"; } // The request carries dates, so criteria differing only in time of day are one Google query — but diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index 0046fd5..9e089cb 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -59,19 +59,60 @@ public async Task IsConfiguredAsync_ResolverThrows_ReturnsFalse() } // Step 1 of a Google call: the arguments, before anything loads configuration. + [Fact] + public async Task SearchEventsAsync_NullCriteria_Throws() + { + var service = CreateService(); + + await Assert.ThrowsAsync(() => service.SearchEventsAsync(null)); + _settingsResolverMock.Verify(x => x.ResolveAsync(It.IsAny()), Times.Never); + } + + // An unnarrowed read is a supported shape, not a missing argument: it resolves the global settings, which is + // the documented store -> global -> default fallback. A consumer whose own store argument is optional reaches + // this on every call that omits it. [Theory] [InlineData(null)] [InlineData("")] - [InlineData(" ")] - public async Task SearchEventsAsync_WithoutStoreId_ThrowsBeforeResolvingSettings(string storeId) + public async Task SearchEventsAsync_WithoutStoreId_ReadsAgainstTheGlobalProperty(string storeId) { + AnalyticsDataQuery capturedQuery = null; + _settingsResolverMock + .Setup(x => x.ResolveAsync(storeId)) + .ReturnsAsync(new AnalyticsDataApiSettings { PropertyId = PropertyId }); + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .Callback((AnalyticsDataQuery query) => capturedQuery = query) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); var service = CreateService(); var criteria = CreateSearchCriteria(); criteria.StoreId = storeId; - await Assert.ThrowsAsync(() => service.SearchEventsAsync(criteria)); - _settingsResolverMock.Verify(x => x.ResolveAsync(It.IsAny()), Times.Never); + var result = await service.SearchEventsAsync(criteria); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(PropertyId, capturedQuery.PropertyId); + } + + // The unnarrowed read still needs a property: with nothing configured globally either, it is a configuration + // error like any other rather than an empty answer. + [Fact] + public async Task SearchEventsAsync_WithoutStoreId_NothingConfiguredGlobally_Throws() + { + _settingsResolverMock + .Setup(x => x.ResolveAsync(null)) + .ReturnsAsync(new AnalyticsDataApiSettings()); + var service = CreateService(); + + var criteria = CreateSearchCriteria(); + criteria.StoreId = null; + + var failure = await Assert.ThrowsAsync(() => service.SearchEventsAsync(criteria)); + + // Not "for store ''": an absent store id is a shape, not a missing value. + Assert.DoesNotContain("''", failure.Message); + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); } // "Not configured" is a configuration error, not an answer: a consumer handed an empty result would present From a4a24116bb648b0d7ed6ac353b27b31c249b6b21 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Wed, 16 Sep 2026 11:12:21 -0600 Subject: [PATCH 25/36] Refactoring (/vc-module-review) --- docs/index.md | 3 +- .../ModuleConstants.cs | 6 +- .../Services/AnalyticsDiagnosticsService.cs | 3 + .../Services/AnalyticsFilterBuilder.cs | 42 +++++++++++ .../Services/AnalyticsService.cs | 37 ++++++++-- .../Services/GoogleAnalyticsDataSource.cs | 30 ++------ .../AnalyticsServiceTests.cs | 69 ++++++++++++++++++- 7 files changed, 152 insertions(+), 38 deletions(-) diff --git a/docs/index.md b/docs/index.md index 9af469f..a56ac39 100644 --- a/docs/index.md +++ b/docs/index.md @@ -132,7 +132,8 @@ response stay in this module's log, because a consumer cannot know how far its o Only the third step is cached. The criteria and the configuration are re-checked on every call, so a store configured a minute after a failed read reports at once instead of after the TTL. `IsConfiguredAsync` is a -question rather than a read, and answers `false` instead of throwing. +question rather than a read, and its `false` means exactly one thing — the settings resolved and carry no property +id. If the settings cannot be resolved at all it throws, like a read: an outage is not an answer. How to degrade is the consumer's decision: catch `AnalyticsException`, show your own unavailable state, and keep "reporting is broken" distinguishable from "this customer did nothing". diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index c6c7a28..48d24a1 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -11,7 +11,7 @@ public static class Permissions { public const string Access = "googleanalytics:access"; - public static string[] AllPermissions { get; } = { Access }; + public static string[] AllPermissions { get; } = [Access]; } } @@ -36,7 +36,7 @@ public static class UserDimensions public const string IsSalesRep = "is_sales_rep"; public const string SessionKind = "session_kind"; - public static string[] AllNames { get; } = { ContactId, OrganizationId, OrganizationName, IsSalesRep, SessionKind }; + public static string[] AllNames { get; } = [ContactId, OrganizationId, OrganizationName, IsSalesRep, SessionKind]; } public static class Dimensions @@ -71,7 +71,7 @@ public static class DiagnosticsStages public const string Realtime = "realtime"; public const string ProcessedData = "processedData"; - public static string[] AllStages { get; } = { Configuration, Credentials, ApiAccess, CustomDimensions, ReportCompatibility, Realtime, ProcessedData }; + public static string[] AllStages { get; } = [Configuration, Credentials, ApiAccess, CustomDimensions, ReportCompatibility, Realtime, ProcessedData]; } public static class DiagnosticsStatuses diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs index c30659d..c9e901b 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -356,6 +356,9 @@ protected virtual CheckCompatibilityRequest BuildCompatibilityRequest(string pro CompatibilityFilter = Compatibility.Incompatible, }; + // Dropped, not refused — unlike a read's dimension filter, which throws. A compatibility probe carries no + // data isolation: an empty name here narrows nothing, so skipping it costs a caller a checked field at + // worst, while failing the stage would hide the answers about the fields they did name. foreach (var dimensionName in (shape.DimensionNames ?? []).Where(x => !string.IsNullOrEmpty(x))) { request.Dimensions.Add(new Dimension { Name = MapDimensionName(dimensionName, userDimensionNames) }); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs index bfe1076..abfcaee 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs @@ -1,18 +1,60 @@ +using System; using System.Collections.Generic; using System.Linq; using Google.Analytics.Data.V1Beta; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; using VirtoCommerce.Platform.Core.Common; namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; internal static class AnalyticsFilterBuilder { + // Naming any of these switches the report to the item scope, where Count means items viewed and the rows + // carry no eventName of their own. + private static readonly string[] ItemDimensionNames = + [ + ModuleConstants.Dimensions.ItemId, + ModuleConstants.Dimensions.ItemName, + ModuleConstants.Dimensions.ItemListName, + ]; + public static string PropertyName(string propertyId) { return $"properties/{propertyId}"; } + // One implementation, two callers on purpose: the service runs it as an argument check, before configuration + // and before the cache, so a caller-caused refusal is never cached; the data source runs it again as the last + // place that can refuse an unscoped read reaching Google. + public static void ValidateDimensionFilters(IList filters, string paramName) + { + foreach (var filter in filters ?? []) + { + // Dropped rather than refused, either of these widens the read instead of narrowing it — and these + // filters carry the consumer's data isolation. + if (string.IsNullOrWhiteSpace(filter?.DimensionName)) + { + throw new ArgumentException( + "A dimension filter carries no dimension name, which would leave the read unscoped.", + paramName); + } + + if (filter.Values.IsNullOrEmpty()) + { + throw new ArgumentException( + $"Dimension filter '{filter.DimensionName}' carries no values, which would leave the read unscoped.", + paramName); + } + } + } + + public static bool HasItemDimensions(IList dimensionNames, IList dimensionFilters) + { + return dimensionNames?.Any(ItemDimensionNames.Contains) == true + || dimensionFilters?.Any(x => ItemDimensionNames.Contains(x?.DimensionName)) == true; + } + // Ordinal on purpose: GA4 field names are case-sensitive, so a mis-cased name is rejected by the API // whether or not it is prefixed, and the response echoes back the exact name that was sent. public static string MapDimensionName(string dimensionName, IList extraUserDimensionNames = null) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index e7f4ddf..96c731e 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -32,6 +32,7 @@ public class AnalyticsService : IAnalyticsService private const string SearchOperation = "events search"; private const string SummariesOperation = "event summaries"; + private const string ConfigurationOperation = "configuration check"; private readonly IAnalyticsSettingsResolver _settingsResolver; private readonly IPlatformMemoryCache _platformMemoryCache; @@ -52,20 +53,25 @@ public AnalyticsService( protected virtual TimeSpan FailureCacheTtl => TimeSpan.FromSeconds(60); - // A question, not a read: the caller is asking whether reading would work at all, so a store that cannot - // report is the answer rather than a failure. + // A question, not a read: "can this store report at all?". `false` therefore means exactly one thing — the + // settings resolved and carry no property id. A resolver that FAILED has not answered the question, and + // saying "no" would put an outage back in the same bucket as "not configured", which is the conflation the + // read path exists to remove. public virtual async Task IsConfiguredAsync(string storeId) { + AnalyticsDataApiSettings settings; + try { - var settings = await _settingsResolver.ResolveAsync(storeId); - return settings.IsConfigured; + settings = await _settingsResolver.ResolveAsync(storeId); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to resolve Google Analytics Data API settings for store {StoreId}", storeId); - return false; + LogFailure(ConfigurationOperation, storeId, ex); + throw CreateReadException(ConfigurationOperation, storeId); } + + return settings.IsConfigured; } public virtual async Task SearchEventsAsync(AnalyticsEventSearchCriteria criteria) @@ -110,10 +116,29 @@ protected virtual T PrepareCriteria(T criteria) where T : AnalyticsEventCriteriaBase { ArgumentNullException.ThrowIfNull(criteria); + ValidateCriteria(criteria); return WithNormalizedDates(criteria); } + // Every refusal a caller can fix by changing its own arguments belongs here, ahead of the configuration and + // the cache: the data source refuses the same two again, but only after the entry that would cache the answer. + protected virtual void ValidateCriteria(AnalyticsEventCriteriaBase criteria) + { + AnalyticsFilterBuilder.ValidateDimensionFilters(criteria.DimensionFilters, nameof(criteria)); + + // Only a search carries extra dimension names; a summary can still reach the item scope through a filter. + var dimensionNames = (criteria as AnalyticsEventSearchCriteria)?.DimensionNames; + + if (criteria.EventNames?.Count > 1 && + AnalyticsFilterBuilder.HasItemDimensions(dimensionNames, criteria.DimensionFilters)) + { + throw new ArgumentException( + "An item-scoped read cannot be narrowed to more than one event name: its rows carry no event name to tell them apart.", + nameof(criteria)); + } + } + protected virtual async Task GetOrCreateAsync( string operation, AnalyticsEventCriteriaBase criteria, diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs index ba674d9..6d39dd8 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs @@ -20,13 +20,6 @@ public class GoogleAnalyticsDataSource : IAnalyticsDataSource private const string DefaultStartDate = "2015-08-14"; private const string DefaultEndDate = "today"; - private static readonly string[] ItemDimensionNames = - { - ModuleConstants.Dimensions.ItemId, - ModuleConstants.Dimensions.ItemName, - ModuleConstants.Dimensions.ItemListName, - }; - private readonly IGoogleAnalyticsReportClient _reportClient; private readonly ILogger _logger; @@ -166,24 +159,12 @@ protected virtual FilterExpression BuildDimensionFilter(AnalyticsDataQuery query expressions.Add(AnalyticsFilterBuilder.CreateInListExpression(ModuleConstants.Dimensions.EventName, query.EventNames)); } + // Already checked at the service entry, before the cache could answer. Repeated here because this is the + // last place that can refuse an unscoped read reaching Google, and IAnalyticsDataSource has its own callers. + AnalyticsFilterBuilder.ValidateDimensionFilters(query.DimensionFilters, nameof(query)); + foreach (var filter in query.DimensionFilters ?? []) { - // Dropped rather than refused, either of these widens the read instead of narrowing it — and these - // filters carry the consumer's data isolation. Last place that can still refuse one. - if (string.IsNullOrWhiteSpace(filter?.DimensionName)) - { - throw new ArgumentException( - "A dimension filter carries no dimension name, which would leave the read unscoped.", - nameof(query)); - } - - if (filter.Values.IsNullOrEmpty()) - { - throw new ArgumentException( - $"Dimension filter '{filter.DimensionName}' carries no values, which would leave the read unscoped.", - nameof(query)); - } - expressions.Add(AnalyticsFilterBuilder.CreateInListExpression(MapDimensionName(filter.DimensionName), filter.Values)); } @@ -258,8 +239,7 @@ protected virtual string UnmapDimensionName(string dimensionName) protected virtual bool HasItemDimensions(AnalyticsDataQuery query) { - return query.DimensionNames?.Any(x => ItemDimensionNames.Contains(x)) == true - || query.DimensionFilters?.Any(x => ItemDimensionNames.Contains(x.DimensionName)) == true; + return AnalyticsFilterBuilder.HasItemDimensions(query.DimensionNames, query.DimensionFilters); } protected static bool IsCountSort(AnalyticsDataQuery query) diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index 9e089cb..affe056 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -47,15 +47,78 @@ public async Task IsConfiguredAsync_Matrix(string propertyId, bool expected) Assert.Equal(expected, await service.IsConfiguredAsync(StoreId)); } + // `false` has to mean exactly "resolved, no property id". A resolver that failed has not answered the + // question, and answering "no" would put an outage back in the same bucket as "not configured". [Fact] - public async Task IsConfiguredAsync_ResolverThrows_ReturnsFalse() + public async Task IsConfiguredAsync_ResolverThrows_ThrowsRatherThanReportingUnconfigured() { _settingsResolverMock .Setup(x => x.ResolveAsync(It.IsAny())) - .ThrowsAsync(new InvalidOperationException("boom")); + .ThrowsAsync(new InvalidOperationException("the store service is down")); var service = CreateService(); - Assert.False(await service.IsConfiguredAsync(StoreId)); + var failure = await Assert.ThrowsAsync(() => service.IsConfiguredAsync(StoreId)); + + Assert.DoesNotContain("store service", failure.Message); + } + + // Step 1, and the point of it being step 1: a caller-caused refusal never reaches the configuration or the + // cache, so fixing the criteria is answered at once rather than after the failure TTL. + [Fact] + public async Task SearchEventsAsync_DimensionFilterWithoutValues_ThrowsBeforeResolvingSettings() + { + var service = CreateService(); + + var criteria = CreateSearchCriteria(); + criteria.DimensionFilters = + [ + new AnalyticsDimensionFilter { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = [] }, + ]; + + await Assert.ThrowsAsync(() => service.SearchEventsAsync(criteria)); + _settingsResolverMock.Verify(x => x.ResolveAsync(It.IsAny()), Times.Never); + _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task SearchEventsAsync_DimensionFilterWithoutName_ThrowsBeforeResolvingSettings(string dimensionName) + { + var service = CreateService(); + + var criteria = CreateSearchCriteria(); + criteria.DimensionFilters = [new AnalyticsDimensionFilter { DimensionName = dimensionName, Values = ["org1"] }]; + + await Assert.ThrowsAsync(() => service.SearchEventsAsync(criteria)); + _settingsResolverMock.Verify(x => x.ResolveAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SearchEventsAsync_ItemScopeWithSeveralEventNames_ThrowsBeforeResolvingSettings() + { + var service = CreateService(); + + var criteria = CreateSearchCriteria(); + criteria.EventNames = [ModuleConstants.EventNames.ViewItem, ModuleConstants.EventNames.AddToCart]; + criteria.DimensionNames = [ModuleConstants.Dimensions.ItemId]; + + await Assert.ThrowsAsync(() => service.SearchEventsAsync(criteria)); + _settingsResolverMock.Verify(x => x.ResolveAsync(It.IsAny()), Times.Never); + } + + // A summary carries no DimensionNames, so a filter is the only way it reaches the item scope. + [Fact] + public async Task GetEventSummariesAsync_ItemScopedFilterWithSeveralEventNames_ThrowsBeforeResolvingSettings() + { + var service = CreateService(); + + var criteria = CreateSummaryCriteria(ModuleConstants.EventNames.ViewItem, ModuleConstants.EventNames.AddToCart); + criteria.DimensionFilters = [new AnalyticsDimensionFilter { DimensionName = ModuleConstants.Dimensions.ItemId, Values = ["SKU-1"] }]; + + await Assert.ThrowsAsync(() => service.GetEventSummariesAsync(criteria)); + _settingsResolverMock.Verify(x => x.ResolveAsync(It.IsAny()), Times.Never); } // Step 1 of a Google call: the arguments, before anything loads configuration. From da8a37a802c4ab4eac61accf250b9b48572df79f Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Wed, 16 Sep 2026 11:23:22 -0600 Subject: [PATCH 26/36] Docs, comments --- docs/index.md | 10 ++-- .../Exceptions/AnalyticsException.cs | 7 ++- .../Services/IAnalyticsService.cs | 2 +- .../Services/AnalyticsFilterBuilder.cs | 12 ++--- .../Services/AnalyticsService.cs | 47 ++++++------------- .../Services/GoogleAnalyticsDataSource.cs | 6 +-- 6 files changed, 30 insertions(+), 54 deletions(-) diff --git a/docs/index.md b/docs/index.md index a56ac39..aea249b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -78,13 +78,12 @@ needed if another module consumes `IAnalyticsService`. Settings > Property Details), e.g. `123456789`. This is *not* the `G-XXXXXXXXXX` measurement id. 1. **GoogleAnalytics4.DataApi.CacheTtlMinutes** - how long a successful report is cached per store and query (default `60`). Data API tokens are metered per property per day, so caching is a quota requirement rather than - tuning; a failed report is cached for a fixed 60 seconds - as the failure it was, not as an empty result - so a - misconfiguration cannot burn quota. Set it to **`0`** to read Google on every call, and note that the platform's + tuning. Set it to **`0`** to read Google on every call, and note that the platform's own `Caching:CacheEnabled=false` is honoured here too - both are for diagnosing stale reporting data, not for normal operation. The cache key covers the **whole** query - store, event names, dimensions, filters, sort and paging as well as dates - together with the resolved property id, so re-pointing a store at another property takes effect at once; `from`/`to` are rounded to the day, so two reads differing only in time of day share one - entry. + entry. Failed reads are cached too, briefly - see [When a read fails](#when-a-read-fails). ## Reading analytics data @@ -130,8 +129,9 @@ The exception names the store and the operation and nothing else. The property i response stay in this module's log, because a consumer cannot know how far its own error surface travels; run `POST api/googleanalytics/{storeId}/diagnostics` to see the cause. -Only the third step is cached. The criteria and the configuration are re-checked on every call, so a store -configured a minute after a failed read reports at once instead of after the TTL. `IsConfiguredAsync` is a +Only the third step is cached — a failure included, for a fixed 60 seconds, so a misconfigured property cannot +burn quota on a hot page. The criteria and the configuration are re-checked on every call, so a store configured +a minute after a failed read reports at once instead of after the TTL. `IsConfiguredAsync` is a question rather than a read, and its `false` means exactly one thing — the settings resolved and carry no property id. If the settings cannot be resolved at all it throws, like a read: an outage is not an answer. diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Exceptions/AnalyticsException.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Exceptions/AnalyticsException.cs index 39c8d8f..b8118ec 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Exceptions/AnalyticsException.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Exceptions/AnalyticsException.cs @@ -2,10 +2,9 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Exceptions; -// Every IAnalyticsService read failure arrives as this — a store with no property id, a refused credential, a -// Google outage. The message names the store and the operation and nothing else: the property id, the settings -// and Google's own response stay in this module's log, because a consumer surfacing the failure has no way to -// know how far its own error surface travels. +// Every IAnalyticsService failure arrives as this — no property id, a refused credential, a Google outage. The +// message names the store and the operation and nothing else; the cause stays in this module's log, because a +// consumer surfacing it cannot know how far its own error surface travels. public class AnalyticsException : Exception { public AnalyticsException(string message) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs index 71e4d54..bc36994 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Services/IAnalyticsService.cs @@ -9,7 +9,7 @@ public interface IAnalyticsService Task IsConfiguredAsync(string storeId); // An item-scoped dimension (itemId / itemName / itemListName) switches the report to the item scope: Count - // then means items viewed, not occurrences, and EventName is filled only when exactly one name was asked for. + // then means items viewed, not occurrences, and the read must name exactly one event name or it is refused. Task SearchEventsAsync(AnalyticsEventSearchCriteria criteria); Task> GetEventSummariesAsync(AnalyticsEventSummaryCriteria criteria); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs index abfcaee..2248f79 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs @@ -10,8 +10,7 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; internal static class AnalyticsFilterBuilder { - // Naming any of these switches the report to the item scope, where Count means items viewed and the rows - // carry no eventName of their own. + // Naming any of these switches the report to the item scope, whose rows carry no eventName of their own. private static readonly string[] ItemDimensionNames = [ ModuleConstants.Dimensions.ItemId, @@ -24,15 +23,14 @@ public static string PropertyName(string propertyId) return $"properties/{propertyId}"; } - // One implementation, two callers on purpose: the service runs it as an argument check, before configuration - // and before the cache, so a caller-caused refusal is never cached; the data source runs it again as the last - // place that can refuse an unscoped read reaching Google. + // Two callers on purpose: the service runs it before the cache, so a caller-caused refusal is never cached; + // the data source runs it again as the last place that can refuse an unscoped read reaching Google. public static void ValidateDimensionFilters(IList filters, string paramName) { foreach (var filter in filters ?? []) { - // Dropped rather than refused, either of these widens the read instead of narrowing it — and these - // filters carry the consumer's data isolation. + // Dropped rather than refused, either of these widens the read — and these filters carry the + // consumer's data isolation. if (string.IsNullOrWhiteSpace(filter?.DimensionName)) { throw new ArgumentException( diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 96c731e..bea028c 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -16,16 +16,14 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; public class AnalyticsService : IAnalyticsService { - // Count mode returns one row per event name, so the totals read is bounded by the names asked for — or, when - // the caller names none, by this. GA4 allows a property up to 500 distinct event names, but a summary over - // hundreds of them is a reporting question, not a feature read: this bounds the probe fan-out below with it. + // Count mode returns one row per event name, so a summary naming none is bounded by this — and so is the + // probe fan-out below. A summary over hundreds of names is a reporting question, not a feature read. private const int MaxEventNames = 50; // Date mode orders by dateHour descending, so the newest bucket is the first row. private const int LatestBucketProbeSize = 1; - // The probes are independent, so they run together instead of nose to tail. Capped rather than unbounded: - // GA4 limits concurrent requests per property, and a criteria naming no events can carry MaxEventNames. + // The probes are independent, so they run together — capped, because GA4 limits concurrent requests. private const int MaxProbeConcurrency = 4; // How the platform expresses Caching:CacheEnabled=false on the options it hands the factory. private static readonly TimeSpan CacheDisabled = TimeSpan.FromTicks(1); @@ -53,10 +51,8 @@ public AnalyticsService( protected virtual TimeSpan FailureCacheTtl => TimeSpan.FromSeconds(60); - // A question, not a read: "can this store report at all?". `false` therefore means exactly one thing — the - // settings resolved and carry no property id. A resolver that FAILED has not answered the question, and - // saying "no" would put an outage back in the same bucket as "not configured", which is the conflation the - // read path exists to remove. + // `false` means exactly one thing: the settings resolved and carry no property id. A resolver that FAILED + // has not answered the question, so it throws — an outage must not read as "not configured". public virtual async Task IsConfiguredAsync(string storeId) { AnalyticsDataApiSettings settings; @@ -107,9 +103,6 @@ public virtual async Task> GetEventSummariesAsync(A return result.Select(x => x.CloneTyped()).ToList(); } - // Step 1 of a Google call: the arguments. Nothing here loads configuration or reaches the cache, so a caller - // that fixes its criteria is answered at once rather than after a TTL. - // // StoreId is deliberately NOT required: an absent one resolves the global settings, which is the documented // store -> global -> default fallback and the shape a consumer uses for an unnarrowed read. protected virtual T PrepareCriteria(T criteria) @@ -121,8 +114,6 @@ protected virtual T PrepareCriteria(T criteria) return WithNormalizedDates(criteria); } - // Every refusal a caller can fix by changing its own arguments belongs here, ahead of the configuration and - // the cache: the data source refuses the same two again, but only after the entry that would cache the answer. protected virtual void ValidateCriteria(AnalyticsEventCriteriaBase criteria) { AnalyticsFilterBuilder.ValidateDimensionFilters(criteria.DimensionFilters, nameof(criteria)); @@ -145,6 +136,8 @@ protected virtual async Task GetOrCreateAsync( Func> factory) where T : class { + // Arguments, then configuration, then the call — and only the call is cached, so fixing a criteria or a + // setting takes effect on the next read instead of after the TTL. var settings = await ResolveSettingsAsync(operation, criteria.StoreId); T result; @@ -155,8 +148,7 @@ protected virtual async Task GetOrCreateAsync( } else { - // The property id belongs in the key because it is what the rows are OF: re-pointing a store at - // another property must not keep serving the previous property's numbers until the TTL runs out. + // The property id is what the rows are OF: re-pointing a store must not keep serving the old numbers. var cacheKey = CacheKey.With(GetType(), operation, settings.PropertyId, criteria.GetCacheKey()); result = await _platformMemoryCache.GetOrCreateExclusiveAsync(cacheKey, @@ -166,8 +158,6 @@ protected virtual async Task GetOrCreateAsync( return result ?? throw CreateReadException(operation, criteria.StoreId); } - // Step 2: the configuration. Cheap, calls nothing, and deliberately outside the cache — a cached "not - // configured" would outlive the setting that fixed it. protected virtual async Task ResolveSettingsAsync(string operation, string storeId) { AnalyticsDataApiSettings settings; @@ -184,17 +174,15 @@ protected virtual async Task ResolveSettingsAsync(stri if (!settings.IsConfigured) { - // Not a state to answer with an empty result: no property id means nothing can be reported at all, - // and a consumer handed "no data" would present that as fact. + // Not a state to answer with an empty result: a consumer handed "no data" would present it as fact. throw new AnalyticsException($"Google Analytics reporting is not configured{DescribeStore(storeId)}."); } return settings; } - // Step 3: the call, and the only step that is cached. A failed call is cached as a null — the entry is what - // keeps a property Google refuses from spending the quota again on every page render — and every reader of - // it is told the read failed rather than handed an empty result. + // A failed call is cached as a null: the entry stops a refused property spending the quota on every page + // render, and every reader of it is still told the read failed. protected virtual async Task ReadAsync( string operation, string storeId, @@ -219,8 +207,6 @@ protected virtual async Task ReadAsync( } } - // Google's own words never leave this module: what a consumer catches names the store and the operation, - // because it cannot know how far its own error surface travels. The cause is in the log line above. protected virtual AnalyticsException CreateReadException(string operation, string storeId) { return new AnalyticsException($"Google Analytics {operation} failed{DescribeStore(storeId)}."); @@ -254,11 +240,9 @@ private static bool IsMidnightOrAbsent(DateTime? value) return value is null || value.Value.TimeOfDay == TimeSpan.Zero; } - // A summary is a sum and a newest-occurrence per event name, and GA has no "max(dateHour)" aggregation — so - // reducing a fetched series here would transfer one row per event name PER HOUR (years of rows) to produce two - // numbers. Two narrow reads answer it instead: 'count' mode collapses to one row per event name carrying the - // summed metric, and a one-row 'date' probe per event name carries its newest bucket. Names with no events at - // all are not probed. + // GA has no "max(dateHour)", so reducing a fetched series would transfer one row per event name PER HOUR to + // produce two numbers. Two narrow reads answer it instead: 'count' mode for the totals, then a one-row 'date' + // probe per name that has any. protected virtual async Task> CreateSummariesAsync( AnalyticsDataApiSettings settings, AnalyticsEventSummaryCriteria criteria) @@ -272,8 +256,7 @@ protected virtual async Task> CreateSummariesAsync( // Count-mode rows carry no date, so the summaries come back with a null LastOccurredAt that the probe fills. var summaries = CreateSummaries(criteria, totals.Events); - // A failed probe fails the whole summary read: its null LastOccurredAt is indistinguishable from "this - // event has never happened", so keeping the totals would hand a consumer a wrong answer as a fact. + // A failed probe fails the whole read: a null LastOccurredAt reads as "never happened". await Parallel.ForEachAsync( summaries.Where(x => x.TotalCount > 0), new ParallelOptions { MaxDegreeOfParallelism = MaxProbeConcurrency }, diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs index 6d39dd8..c609aa0 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs @@ -46,9 +46,7 @@ protected virtual async Task GetEventScopedRowsAsync // kept as a separate path so it can be reworked without touching the main query path. protected virtual async Task GetItemScopedRowsAsync(AnalyticsDataQuery query) { - // The item report carries no eventName dimension, so every row arrives with a null EventName and only a - // single requested name can be filled back in below. Two or more would reach a consumer as rows it cannot - // attribute — and through CreateSummaries as zero counts indistinguishable from no activity at all. + // Refused at the service entry too; this is the backstop for a direct IAnalyticsDataSource caller. if (query.EventNames?.Count > 1) { throw new ArgumentException( @@ -159,8 +157,6 @@ protected virtual FilterExpression BuildDimensionFilter(AnalyticsDataQuery query expressions.Add(AnalyticsFilterBuilder.CreateInListExpression(ModuleConstants.Dimensions.EventName, query.EventNames)); } - // Already checked at the service entry, before the cache could answer. Repeated here because this is the - // last place that can refuse an unscoped read reaching Google, and IAnalyticsDataSource has its own callers. AnalyticsFilterBuilder.ValidateDimensionFilters(query.DimensionFilters, nameof(query)); foreach (var filter in query.DimensionFilters ?? []) From 375f9b7a26019248648ae93d64adab89609faa34 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 17 Sep 2026 07:06:18 -0600 Subject: [PATCH 27/36] index.md --- docs/index.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/index.md b/docs/index.md index aea249b..9b47f23 100644 --- a/docs/index.md +++ b/docs/index.md @@ -129,6 +129,12 @@ The exception names the store and the operation and nothing else. The property i response stay in this module's log, because a consumer cannot know how far its own error surface travels; run `POST api/googleanalytics/{storeId}/diagnostics` to see the cause. +A summary read fans out — one totals read plus a newest-occurrence probe per event name — and **any** of +them failing fails the whole summary. An earlier design isolated a failed probe and returned the totals with a +null `LastOccurredAt`; that was reversed deliberately, because a null there reads as "this event never happened" +and a consumer presents it as fact. The cost of the current shape is narrower but real: one transient probe +failure costs the whole summary until the failure entry expires, where before it cost a single field. + Only the third step is cached — a failure included, for a fixed 60 seconds, so a misconfigured property cannot burn quota on a hot page. The criteria and the configuration are re-checked on every call, so a store configured a minute after a failed read reports at once instead of after the TTL. `IsConfiguredAsync` is a From 2bc5e8c86fd32a2840695982059a208fae5dbea6 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 17 Sep 2026 09:23:38 -0600 Subject: [PATCH 28/36] Comment --- .../Services/IAnalyticsDataSource.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs index c5f625d..212538b 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IAnalyticsDataSource.cs @@ -4,8 +4,9 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; -// The provider seam behind IAnalyticsService: register a different implementation to answer the same queries -// from another source (a first-party event store, a BigQuery export) without changing a consumer. +// The seam the GA4 implementation sits behind: subclass it to reshape a report, or double it in tests. Not a +// provider boundary, deliberately — AnalyticsDataQuery is GA4-shaped (a numeric property id, GA4 apiNames +// as dimension names) and another source would need query fields this one has no place for. public interface IAnalyticsDataSource { Task GetRowsAsync(AnalyticsDataQuery query); From f88af53b8814d078150700c5a5735a3d045db03f Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 17 Sep 2026 10:35:19 -0600 Subject: [PATCH 29/36] Additional tests --- ...GoogleEcommerceAnalyticsModule.Data.csproj | 5 + .../AnalyticsFilterBuilderTests.cs | 205 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj index ff64d6e..708843f 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.csproj @@ -16,6 +16,11 @@ + + + + diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs new file mode 100644 index 0000000..1aa380d --- /dev/null +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; +using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; +using Xunit; + +namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; + +// Every dimension filter a read sends to Google is built here, including the ones carrying a consumer's data +// isolation — and the two guards below are what the service and the data source both call. Reached only +// indirectly they were pinned by whichever caller happened to exercise them. +public class AnalyticsFilterBuilderTests +{ + private const string ParamName = "criteria"; + + [Fact] + public void PropertyName_PrefixesTheNumericId() + { + Assert.Equal("properties/123456", AnalyticsFilterBuilder.PropertyName("123456")); + } + + // Dropped rather than refused, a nameless filter widens the read to every organization. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ValidateDimensionFilters_NoDimensionName_Throws(string dimensionName) + { + var filters = CreateFilters(new AnalyticsDimensionFilter { DimensionName = dimensionName, Values = ["org1"] }); + + var exception = Assert.Throws(() => AnalyticsFilterBuilder.ValidateDimensionFilters(filters, ParamName)); + + Assert.Equal(ParamName, exception.ParamName); + } + + [Fact] + public void ValidateDimensionFilters_NullFilterInTheList_Throws() + { + var filters = CreateFilters((AnalyticsDimensionFilter)null); + + Assert.Throws(() => AnalyticsFilterBuilder.ValidateDimensionFilters(filters, ParamName)); + } + + [Fact] + public void ValidateDimensionFilters_NoValues_ThrowsNamingTheDimension() + { + var filters = CreateFilters(new AnalyticsDimensionFilter + { + DimensionName = ModuleConstants.UserDimensions.OrganizationId, + Values = [], + }); + + var exception = Assert.Throws(() => AnalyticsFilterBuilder.ValidateDimensionFilters(filters, ParamName)); + + // The message has to say WHICH filter: a consumer builds several and only one of them is wrong. + Assert.Contains(ModuleConstants.UserDimensions.OrganizationId, exception.Message); + } + + // No filters at all is a legitimate unscoped read — the guard refuses a filter that pretends to scope and + // does not, not the absence of one. + [Fact] + public void ValidateDimensionFilters_NullOrEmptyList_DoesNotThrow() + { + AnalyticsFilterBuilder.ValidateDimensionFilters(null, ParamName); + AnalyticsFilterBuilder.ValidateDimensionFilters([], ParamName); + } + + [Fact] + public void ValidateDimensionFilters_EveryFilterScoped_DoesNotThrow() + { + var filters = CreateFilters( + new AnalyticsDimensionFilter { DimensionName = ModuleConstants.UserDimensions.SessionKind, Values = ["self"] }, + new AnalyticsDimensionFilter { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = ["org1", "org2"] }); + + AnalyticsFilterBuilder.ValidateDimensionFilters(filters, ParamName); + } + + // The second filter is the malformed one: a guard that only looks at the first would pass this. + [Fact] + public void ValidateDimensionFilters_ChecksEveryFilterNotJustTheFirst() + { + var filters = CreateFilters( + new AnalyticsDimensionFilter { DimensionName = ModuleConstants.UserDimensions.SessionKind, Values = ["self"] }, + new AnalyticsDimensionFilter { DimensionName = ModuleConstants.UserDimensions.OrganizationId, Values = [] }); + + Assert.Throws(() => AnalyticsFilterBuilder.ValidateDimensionFilters(filters, ParamName)); + } + + [Theory] + [InlineData(ModuleConstants.Dimensions.ItemId)] + [InlineData(ModuleConstants.Dimensions.ItemName)] + [InlineData(ModuleConstants.Dimensions.ItemListName)] + public void HasItemDimensions_NamedAsADimension_IsTrue(string dimensionName) + { + Assert.True(AnalyticsFilterBuilder.HasItemDimensions([dimensionName], null)); + } + + // A summary carries no DimensionNames, so a filter is the only way it reaches the item scope. + [Fact] + public void HasItemDimensions_NamedInAFilter_IsTrue() + { + var filters = CreateFilters(new AnalyticsDimensionFilter + { + DimensionName = ModuleConstants.Dimensions.ItemId, + Values = ["SKU-1"], + }); + + Assert.True(AnalyticsFilterBuilder.HasItemDimensions(null, filters)); + } + + [Fact] + public void HasItemDimensions_EventScopedOnly_IsFalse() + { + var filters = CreateFilters(new AnalyticsDimensionFilter + { + DimensionName = ModuleConstants.UserDimensions.OrganizationId, + Values = ["org1"], + }); + + Assert.False(AnalyticsFilterBuilder.HasItemDimensions([ModuleConstants.Dimensions.SearchTerm], filters)); + Assert.False(AnalyticsFilterBuilder.HasItemDimensions(null, null)); + } + + // A null entry must not fault the scope check the way it would a plain Contains over the names. + [Fact] + public void HasItemDimensions_NullFilterInTheList_IsFalse() + { + Assert.False(AnalyticsFilterBuilder.HasItemDimensions(null, CreateFilters((AnalyticsDimensionFilter)null))); + } + + // GA4 field names are case-sensitive, so the prefix is applied on an exact match only: a mis-cased name is + // passed through and rejected by the API rather than silently turned into a different field. + [Fact] + public void MapDimensionName_KnownUserDimension_GetsThePrefix() + { + Assert.Equal( + ModuleConstants.UserDimensions.Prefix + ModuleConstants.UserDimensions.OrganizationId, + AnalyticsFilterBuilder.MapDimensionName(ModuleConstants.UserDimensions.OrganizationId)); + } + + [Theory] + [InlineData(ModuleConstants.Dimensions.SearchTerm)] + [InlineData("ORGANIZATION_ID")] + public void MapDimensionName_NotAKnownUserDimension_IsPassedThrough(string dimensionName) + { + Assert.Equal(dimensionName, AnalyticsFilterBuilder.MapDimensionName(dimensionName)); + } + + // Diagnostics probes a caller-supplied dimension that is not one of the five built-in names. + [Fact] + public void MapDimensionName_ExtraUserDimension_GetsThePrefix() + { + Assert.Equal( + ModuleConstants.UserDimensions.Prefix + "tenant_id", + AnalyticsFilterBuilder.MapDimensionName("tenant_id", ["tenant_id"])); + } + + [Fact] + public void CreateInListExpression_CarriesTheFieldAndEveryValue() + { + var expression = AnalyticsFilterBuilder.CreateInListExpression("customUser:organization_id", ["org1", "org2"]); + + Assert.Equal("customUser:organization_id", expression.Filter.FieldName); + Assert.Equal(["org1", "org2"], expression.Filter.InListFilter.Values); + } + + [Fact] + public void Combine_NoExpressions_IsNull() + { + Assert.Null(AnalyticsFilterBuilder.Combine(null)); + Assert.Null(AnalyticsFilterBuilder.Combine([])); + } + + // One filter needs no AND group, and wrapping it would change the request shape for no reason. + [Fact] + public void Combine_OneExpression_IsReturnedUnwrapped() + { + var expression = AnalyticsFilterBuilder.CreateInListExpression("eventName", ["search"]); + + Assert.Same(expression, AnalyticsFilterBuilder.Combine([expression])); + } + + // AND across filters is the isolation rule: session kind AND organization, never either one alone. + [Fact] + public void Combine_SeveralExpressions_AndsThemTogether() + { + var first = AnalyticsFilterBuilder.CreateInListExpression("customUser:session_kind", ["self"]); + var second = AnalyticsFilterBuilder.CreateInListExpression("customUser:organization_id", ["org1"]); + + var combined = AnalyticsFilterBuilder.Combine([first, second]); + + Assert.NotNull(combined.AndGroup); + Assert.Equal(2, combined.AndGroup.Expressions.Count); + Assert.Equal( + ["customUser:organization_id", "customUser:session_kind"], + combined.AndGroup.Expressions.Select(x => x.Filter.FieldName).OrderBy(x => x, StringComparer.Ordinal)); + } + + private static IList CreateFilters(params AnalyticsDimensionFilter[] filters) + { + return [.. filters]; + } +} From bd937caa6133e7c928aa3760b6dfbc515a0d0a13 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 17 Sep 2026 10:46:48 -0600 Subject: [PATCH 30/36] Comment --- .../AnalyticsFilterBuilderTests.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs index 1aa380d..9a217b2 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs @@ -9,8 +9,7 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; // Every dimension filter a read sends to Google is built here, including the ones carrying a consumer's data -// isolation — and the two guards below are what the service and the data source both call. Reached only -// indirectly they were pinned by whichever caller happened to exercise them. +// isolation, and both guards below have two callers. Reached only indirectly, they were pinned by neither. public class AnalyticsFilterBuilderTests { private const string ParamName = "criteria"; @@ -58,8 +57,7 @@ public void ValidateDimensionFilters_NoValues_ThrowsNamingTheDimension() Assert.Contains(ModuleConstants.UserDimensions.OrganizationId, exception.Message); } - // No filters at all is a legitimate unscoped read — the guard refuses a filter that pretends to scope and - // does not, not the absence of one. + // The guard refuses a filter that pretends to scope and does not, not the absence of one. [Fact] public void ValidateDimensionFilters_NullOrEmptyList_DoesNotThrow() { @@ -130,8 +128,7 @@ public void HasItemDimensions_NullFilterInTheList_IsFalse() Assert.False(AnalyticsFilterBuilder.HasItemDimensions(null, CreateFilters((AnalyticsDimensionFilter)null))); } - // GA4 field names are case-sensitive, so the prefix is applied on an exact match only: a mis-cased name is - // passed through and rejected by the API rather than silently turned into a different field. + // GA4 field names are case-sensitive, so the prefix is applied on an exact match only. [Fact] public void MapDimensionName_KnownUserDimension_GetsThePrefix() { From e305c55349de381d5da6179dd94adcd529d88762 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Fri, 9 Oct 2026 15:46:21 -0600 Subject: [PATCH 31/36] Add a Data API request timeout setting (default 45 s) GoogleAnalytics4.DataApi.RequestTimeoutSeconds, global and per store like the cache lifetime, becomes the deadline of every call the module makes to Google, diagnostics included. The client library's own default (60 s, never retried) matched the ~60 s an ingress gives a whole request, so a slow Google answer surfaced as a gateway timeout of the consumer's request instead of a failed read the consumer degrades on. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- docs/index.md | 9 ++- .../ModuleConstants.cs | 14 +++++ .../Models/AnalyticsDataApiSettings.cs | 7 +++ .../Models/AnalyticsDataQuery.cs | 2 + .../Services/AnalyticsDiagnosticsService.cs | 10 ++-- .../Services/AnalyticsService.cs | 1 + .../Services/AnalyticsSettingsResolver.cs | 1 + .../Services/GoogleAnalyticsDataSource.cs | 4 +- .../Services/GoogleAnalyticsReportClient.cs | 24 +++++--- .../Services/IGoogleAnalyticsReportClient.cs | 9 +-- ...irtoCommerce.GoogleEcommerceAnalytics.json | 4 ++ ...irtoCommerce.GoogleEcommerceAnalytics.json | 4 ++ ...irtoCommerce.GoogleEcommerceAnalytics.json | 4 ++ ...irtoCommerce.GoogleEcommerceAnalytics.json | 4 ++ ...irtoCommerce.GoogleEcommerceAnalytics.json | 4 ++ ...irtoCommerce.GoogleEcommerceAnalytics.json | 4 ++ ...irtoCommerce.GoogleEcommerceAnalytics.json | 4 ++ ...irtoCommerce.GoogleEcommerceAnalytics.json | 4 ++ ...irtoCommerce.GoogleEcommerceAnalytics.json | 4 ++ ...irtoCommerce.GoogleEcommerceAnalytics.json | 4 ++ .../AnalyticsDiagnosticsServiceTests.cs | 57 +++++++++++++------ .../AnalyticsServiceTests.cs | 31 ++++++++++ .../AnalyticsSettingsResolverTests.cs | 48 ++++++++++++++++ .../GoogleAnalyticsDataSourceTests.cs | 36 ++++++++++-- .../GoogleAnalyticsReportClientTests.cs | 27 +++++++++ 26 files changed, 279 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index b11d99a..040595a 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,9 @@ GraphQL surface; consumers build their own fields on top of it (the first is [VirtoCommerce.SalesRep](https://github.com/VirtoCommerce/vc-module-sales-rep), which turns it into rep-facing customer insights). -Reporting needs two extra store settings — the numeric **GA4 property id** and a report cache lifetime — plus -**Application Default Credentials** with the `analytics.readonly` scope and Viewer on the property. There is -deliberately no credential *setting*, so no key material is stored in the platform database. +Reporting needs extra store settings — the numeric **GA4 property id**, a report cache lifetime and a report request +timeout — plus **Application Default Credentials** with the `analytics.readonly` scope and Viewer on the property. +There is deliberately no credential *setting*, so no key material is stored in the platform database. `POST api/googleanalytics/{storeId}/diagnostics` runs a staged checklist that names exactly what is missing. Reads **throw** rather than answer a failure with an empty result — a store with no property id, a refused diff --git a/docs/index.md b/docs/index.md index 9b47f23..3690a86 100644 --- a/docs/index.md +++ b/docs/index.md @@ -84,6 +84,11 @@ needed if another module consumes `IAnalyticsService`. paging as well as dates - together with the resolved property id, so re-pointing a store at another property takes effect at once; `from`/`to` are rounded to the day, so two reads differing only in time of day share one entry. Failed reads are cached too, briefly - see [When a read fails](#when-a-read-fails). +1. **GoogleAnalytics4.DataApi.RequestTimeoutSeconds** - how long one request to Google may take before it is abandoned + and the read fails (default `45`). It replaces the client library's own default (60 seconds, never retried) on + every call the module makes, diagnostics included. The default stays under the ~60 seconds an ingress commonly + gives a whole HTTP request, so a slow answer from Google fails the read - which the consumer degrades on - instead + of the gateway failing the consumer's request. ## Reading analytics data @@ -122,8 +127,8 @@ refuse: it resolves the global settings, which is the documented store → global → default fallback; * no property id configured, for the store or globally — `AnalyticsException`. Not being configured is a configuration error, not a state to be reported as "no activity"; -* anything Google refuses — a property that does not exist, a credential without access, a quota rejection — - `AnalyticsException`. +* anything Google refuses — a property that does not exist, a credential without access, a quota rejection — or + does not answer within `RequestTimeoutSeconds` — `AnalyticsException`. The exception names the store and the operation and nothing else. The property id, the settings and Google's own response stay in this module's log, because a consumer cannot know how far its own error surface travels; run diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index 48d24a1..253107c 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -136,6 +136,10 @@ public static class DataApi { public const int DefaultCacheTtlMinutes = 60; + // Below the ~60 s an ingress typically gives a request: a slow Google answer then fails the read, which + // the consumer degrades on, instead of the gateway failing the consumer's whole request. + public const int DefaultRequestTimeoutSeconds = 45; + public static SettingDescriptor PropertyId { get; } = new SettingDescriptor { Name = "GoogleAnalytics4.DataApi.PropertyId", @@ -151,12 +155,21 @@ public static class DataApi DefaultValue = DefaultCacheTtlMinutes }; + public static SettingDescriptor RequestTimeoutSeconds { get; } = new SettingDescriptor + { + Name = "GoogleAnalytics4.DataApi.RequestTimeoutSeconds", + GroupName = "Google Analytics 4", + ValueType = SettingValueType.PositiveInteger, + DefaultValue = DefaultRequestTimeoutSeconds + }; + public static IEnumerable AllSettings { get { yield return PropertyId; yield return CacheTtlMinutes; + yield return RequestTimeoutSeconds; } } } @@ -170,6 +183,7 @@ public static IEnumerable StoreLevelSettings yield return General.GtmContainerId; yield return DataApi.PropertyId; yield return DataApi.CacheTtlMinutes; + yield return DataApi.RequestTimeoutSeconds; } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs index 51ea984..1d4fe7e 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataApiSettings.cs @@ -1,3 +1,4 @@ +using System; using DataApiSettings = VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.ModuleConstants.Settings.DataApi; namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; @@ -9,6 +10,12 @@ public class AnalyticsDataApiSettings // The descriptor's default: 0 now means "disabled", so an unset instance must not land on it. public int CacheTtlMinutes { get; set; } = DataApiSettings.DefaultCacheTtlMinutes; + public int RequestTimeoutSeconds { get; set; } = DataApiSettings.DefaultRequestTimeoutSeconds; + + // No "disabled" here: a call without a deadline is one Google can hold for as long as it likes. + public TimeSpan RequestTimeout => TimeSpan.FromSeconds( + RequestTimeoutSeconds > 0 ? RequestTimeoutSeconds : DataApiSettings.DefaultRequestTimeoutSeconds); + // Whitespace is not a property id: a value of spaces would otherwise report as configured and build // "properties/ " for every read. public bool IsConfigured => !string.IsNullOrWhiteSpace(PropertyId); diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs index bb193da..35ba828 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Models/AnalyticsDataQuery.cs @@ -24,4 +24,6 @@ public class AnalyticsDataQuery public int Take { get; set; } public int Skip { get; set; } + + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(ModuleConstants.Settings.DataApi.DefaultRequestTimeoutSeconds); } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs index c9e901b..96f3066 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -126,7 +126,7 @@ protected virtual async Task CheckApiAccessAsync(AnalyticsDataApiSetti { try { - var metadata = await _reportClient.GetMetadataAsync(settings.PropertyId); + var metadata = await _reportClient.GetMetadataAsync(settings.PropertyId, settings.RequestTimeout); if (metadata == null) { AddCheck(checks, Stages.ApiAccess, Statuses.Failed, "Google Analytics Data API returned no metadata."); @@ -231,7 +231,7 @@ protected virtual async Task CheckReportCompatibilityAsync(AnalyticsDataApiSetti foreach (var shape in shapes) { var compatibilityRequest = BuildCompatibilityRequest(settings.PropertyId, shape, userDimensionNames); - var response = await _reportClient.CheckCompatibilityAsync(compatibilityRequest); + var response = await _reportClient.CheckCompatibilityAsync(compatibilityRequest, settings.RequestTimeout); var incompatibleFields = GetIncompatibleFields(response, GetRequestedFieldNames(compatibilityRequest)); if (incompatibleFields.Count > 0) @@ -286,13 +286,13 @@ protected virtual async Task CheckRealtimeAsync(AnalyticsDataApiSettings setting try { response = await _reportClient.RunRealtimeReportAsync( - BuildRealtimeRequest(settings.PropertyId, userDimensionNames)); + BuildRealtimeRequest(settings.PropertyId, userDimensionNames), settings.RequestTimeout); } catch (RpcException ex) when (userDimensionNames.Count > 0 && ex.StatusCode == StatusCode.InvalidArgument) { dimensionFailure = ex; response = await _reportClient.RunRealtimeReportAsync( - BuildRealtimeRequest(settings.PropertyId, [])); + BuildRealtimeRequest(settings.PropertyId, []), settings.RequestTimeout); } var fallbackNote = dimensionFailure != null @@ -326,7 +326,7 @@ protected virtual async Task CheckProcessedDataAsync(AnalyticsDataApiSettings se { try { - var response = await _reportClient.RunReportAsync(BuildProcessedDataRequest(settings.PropertyId)); + var response = await _reportClient.RunReportAsync(BuildProcessedDataRequest(settings.PropertyId), settings.RequestTimeout); var eventCounts = AggregateEventCounts(response.DimensionHeaders, response.Rows); if (eventCounts.Count == 0) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index bea028c..3e6fbe6 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -289,6 +289,7 @@ protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settin query.DimensionFilters = criteria.DimensionFilters; query.From = criteria.From; query.To = criteria.To; + query.RequestTimeout = settings.RequestTimeout; return query; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs index 56bd9d0..0b648b1 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs @@ -36,6 +36,7 @@ public virtual async Task ResolveAsync(string storeId) result.PropertyId = await GetSettingAsync(storeSettings, DataApiSettings.PropertyId); result.CacheTtlMinutes = await GetSettingAsync(storeSettings, DataApiSettings.CacheTtlMinutes); + result.RequestTimeoutSeconds = await GetSettingAsync(storeSettings, DataApiSettings.RequestTimeoutSeconds); return result; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs index c609aa0..6c30766 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs @@ -38,7 +38,7 @@ public virtual Task GetRowsAsync(AnalyticsDataQuery protected virtual async Task GetEventScopedRowsAsync(AnalyticsDataQuery query) { - var response = await _reportClient.RunReportAsync(BuildEventReportRequest(query)); + var response = await _reportClient.RunReportAsync(BuildEventReportRequest(query), query.RequestTimeout); return MapResponse(response, query); } @@ -54,7 +54,7 @@ protected virtual async Task GetItemScopedRowsAsync( nameof(query)); } - var response = await _reportClient.RunReportAsync(BuildItemReportRequest(query)); + var response = await _reportClient.RunReportAsync(BuildItemReportRequest(query), query.RequestTimeout); var result = MapResponse(response, query); var eventName = query.EventNames?.Count == 1 ? query.EventNames[0] : null; diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs index daa53f5..3e0d0ee 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs @@ -2,6 +2,8 @@ using System.Threading; using System.Threading.Tasks; using Google.Analytics.Data.V1Beta; +using Google.Api.Gax; +using Google.Api.Gax.Grpc; using Google.Apis.Auth.OAuth2; namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; @@ -26,28 +28,34 @@ public virtual Task ValidateCredentialAsync() return GetCredentialAsync(); } - public virtual async Task GetMetadataAsync(string propertyId) + public virtual async Task GetMetadataAsync(string propertyId, TimeSpan timeout) { var client = await GetClientAsync(); - return await client.GetMetadataAsync(new GetMetadataRequest { Name = $"properties/{propertyId}/metadata" }); + return await client.GetMetadataAsync(new GetMetadataRequest { Name = $"properties/{propertyId}/metadata" }, CreateCallSettings(timeout)); } - public virtual async Task RunReportAsync(RunReportRequest request) + public virtual async Task RunReportAsync(RunReportRequest request, TimeSpan timeout) { var client = await GetClientAsync(); - return await client.RunReportAsync(request); + return await client.RunReportAsync(request, CreateCallSettings(timeout)); } - public virtual async Task RunRealtimeReportAsync(RunRealtimeReportRequest request) + public virtual async Task RunRealtimeReportAsync(RunRealtimeReportRequest request, TimeSpan timeout) { var client = await GetClientAsync(); - return await client.RunRealtimeReportAsync(request); + return await client.RunRealtimeReportAsync(request, CreateCallSettings(timeout)); } - public virtual async Task CheckCompatibilityAsync(CheckCompatibilityRequest request) + public virtual async Task CheckCompatibilityAsync(CheckCompatibilityRequest request, TimeSpan timeout) { var client = await GetClientAsync(); - return await client.CheckCompatibilityAsync(request); + return await client.CheckCompatibilityAsync(request, CreateCallSettings(timeout)); + } + + // Overlays the library's per-method default (60 s, never retried) with the store's deadline. + protected virtual CallSettings CreateCallSettings(TimeSpan timeout) + { + return CallSettings.FromExpiration(Expiration.FromTimeout(timeout)); } protected virtual Task GetClientAsync() diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs index 1e896fa..fd5fe48 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/IGoogleAnalyticsReportClient.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using Google.Analytics.Data.V1Beta; @@ -7,11 +8,11 @@ public interface IGoogleAnalyticsReportClient { Task ValidateCredentialAsync(); - Task GetMetadataAsync(string propertyId); + Task GetMetadataAsync(string propertyId, TimeSpan timeout); - Task RunReportAsync(RunReportRequest request); + Task RunReportAsync(RunReportRequest request, TimeSpan timeout); - Task RunRealtimeReportAsync(RunRealtimeReportRequest request); + Task RunRealtimeReportAsync(RunRealtimeReportRequest request, TimeSpan timeout); - Task CheckCompatibilityAsync(CheckCompatibilityRequest request); + Task CheckCompatibilityAsync(CheckCompatibilityRequest request, TimeSpan timeout); } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/de.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/de.VirtoCommerce.GoogleEcommerceAnalytics.json index 12f43a0..dfa4207 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/de.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/de.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -25,6 +25,10 @@ "CacheTtlMinutes": { "title": "Cache-Lebensdauer der Berichte, Minuten", "description": "Wie lange Berichtsergebnisse von Google Analytics pro Store zwischengespeichert werden" + }, + "RequestTimeoutSeconds": { + "title": "Zeitlimit für Berichtsanfragen, Sekunden", + "description": "Wie lange auf die Antwort von Google Analytics auf eine Berichtsanfrage gewartet wird, bevor der Abruf fehlschlägt" } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/en.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/en.VirtoCommerce.GoogleEcommerceAnalytics.json index 6088fbc..865941b 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/en.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/en.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -25,6 +25,10 @@ "CacheTtlMinutes": { "title": "Report cache lifetime, minutes", "description": "How long Google Analytics report results are cached per store" + }, + "RequestTimeoutSeconds": { + "title": "Report request timeout, seconds", + "description": "How long to wait for Google Analytics to answer one report request before the read fails" } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/es.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/es.VirtoCommerce.GoogleEcommerceAnalytics.json index eec4fd6..d366b8b 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/es.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/es.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -25,6 +25,10 @@ "CacheTtlMinutes": { "title": "Duración de la caché de informes, minutos", "description": "Cuánto tiempo se almacenan en caché los resultados de los informes de Google Analytics por tienda" + }, + "RequestTimeoutSeconds": { + "title": "Tiempo de espera de las solicitudes de informes, segundos", + "description": "Cuánto tiempo se espera la respuesta de Google Analytics a una solicitud de informe antes de que la lectura falle" } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/fr.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/fr.VirtoCommerce.GoogleEcommerceAnalytics.json index 56e1f43..c01916f 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/fr.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/fr.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -25,6 +25,10 @@ "CacheTtlMinutes": { "title": "Durée du cache des rapports, minutes", "description": "Durée de mise en cache des résultats des rapports Google Analytics par magasin" + }, + "RequestTimeoutSeconds": { + "title": "Délai d'attente des requêtes de rapport, secondes", + "description": "Durée d'attente de la réponse de Google Analytics à une requête de rapport avant l'échec de la lecture" } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/it.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/it.VirtoCommerce.GoogleEcommerceAnalytics.json index 39bb3c9..5403b75 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/it.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/it.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -25,6 +25,10 @@ "CacheTtlMinutes": { "title": "Durata della cache dei report, minuti", "description": "Per quanto tempo i risultati dei report di Google Analytics vengono memorizzati nella cache per negozio" + }, + "RequestTimeoutSeconds": { + "title": "Timeout delle richieste di report, secondi", + "description": "Per quanto tempo si attende la risposta di Google Analytics a una richiesta di report prima che la lettura non riesca" } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ja.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ja.VirtoCommerce.GoogleEcommerceAnalytics.json index d119bdf..d4f596d 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ja.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ja.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -25,6 +25,10 @@ "CacheTtlMinutes": { "title": "レポートキャッシュの有効期間(分)", "description": "ストアごとに Google Analytics のレポート結果をキャッシュする時間" + }, + "RequestTimeoutSeconds": { + "title": "レポートリクエストのタイムアウト(秒)", + "description": "1 件のレポートリクエストに対する Google Analytics の応答を待つ時間。超えると読み取りは失敗します" } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pl.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pl.VirtoCommerce.GoogleEcommerceAnalytics.json index c18f592..d81ca1a 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pl.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pl.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -25,6 +25,10 @@ "CacheTtlMinutes": { "title": "Czas życia pamięci podręcznej raportów, minuty", "description": "Jak długo wyniki raportów Google Analytics są przechowywane w pamięci podręcznej dla sklepu" + }, + "RequestTimeoutSeconds": { + "title": "Limit czasu żądań raportów, sekundy", + "description": "Jak długo czekać na odpowiedź Google Analytics na jedno żądanie raportu, zanim odczyt zakończy się niepowodzeniem" } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pt.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pt.VirtoCommerce.GoogleEcommerceAnalytics.json index 2ad7b99..ed6cb84 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pt.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/pt.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -25,6 +25,10 @@ "CacheTtlMinutes": { "title": "Tempo de cache dos relatórios, minutos", "description": "Por quanto tempo os resultados dos relatórios do Google Analytics são armazenados em cache por loja" + }, + "RequestTimeoutSeconds": { + "title": "Tempo limite das solicitações de relatórios, segundos", + "description": "Quanto tempo esperar pela resposta do Google Analytics a uma solicitação de relatório antes que a leitura falhe" } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ru.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ru.VirtoCommerce.GoogleEcommerceAnalytics.json index e92b84b..66e41d2 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ru.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/ru.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -25,6 +25,10 @@ "CacheTtlMinutes": { "title": "Время жизни кэша отчётов, минуты", "description": "Как долго результаты отчётов Google Analytics кэшируются для магазина" + }, + "RequestTimeoutSeconds": { + "title": "Тайм-аут запроса отчёта, секунды", + "description": "Сколько ждать ответа Google Analytics на один запрос отчёта, прежде чем чтение завершится ошибкой" } } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/zh.VirtoCommerce.GoogleEcommerceAnalytics.json b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/zh.VirtoCommerce.GoogleEcommerceAnalytics.json index 2527769..adee932 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/zh.VirtoCommerce.GoogleEcommerceAnalytics.json +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Web/Localizations/zh.VirtoCommerce.GoogleEcommerceAnalytics.json @@ -25,6 +25,10 @@ "CacheTtlMinutes": { "title": "报告缓存有效期(分钟)", "description": "每个商店的 Google Analytics 报告结果缓存时长" + }, + "RequestTimeoutSeconds": { + "title": "报告请求超时(秒)", + "description": "等待 Google Analytics 响应单个报告请求的时长,超时后读取失败" } } } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs index 19694e2..7f7c00f 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs @@ -144,7 +144,7 @@ public async Task Credentials_AdcNotFound_FailsWithOperatorAdviceAndSkipsRest() Assert.Contains("gcloud auth application-default login", credentials.Message); Assert.Equal("The Application Default Credentials are not available.", credentials.Detail); Assert.All(result.Checks.Skip(2), x => Assert.Equal(Statuses.Skipped, x.Status)); - _reportClientMock.Verify(x => x.GetMetadataAsync(It.IsAny()), Times.Never); + _reportClientMock.Verify(x => x.GetMetadataAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -310,7 +310,7 @@ public async Task ReportCompatibility_ClientThrows_FailsStageButLiveDataStillRun { SetupHappyGooglePath(); _reportClientMock - .Setup(x => x.CheckCompatibilityAsync(It.IsAny())) + .Setup(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("credentials missing")); var service = CreateService(); @@ -334,7 +334,7 @@ public async Task ReportCompatibility_NoShapes_Skipped() var result = await service.RunAsync(StoreId, request); Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.ReportCompatibility).Status); - _reportClientMock.Verify(x => x.CheckCompatibilityAsync(It.IsAny()), Times.Never); + _reportClientMock.Verify(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -342,8 +342,8 @@ public async Task Realtime_CustomDimensionsRejected_RetriesEventNameOnly() { SetupHappyGooglePath(); _reportClientMock - .Setup(x => x.RunRealtimeReportAsync(It.Is(r => r.Dimensions.Count > 1))) - .Callback((RunRealtimeReportRequest request) => _capturedRealtimeRequests.Add(request)) + .Setup(x => x.RunRealtimeReportAsync(It.Is(r => r.Dimensions.Count > 1), It.IsAny())) + .Callback((RunRealtimeReportRequest request, TimeSpan _) => _capturedRealtimeRequests.Add(request)) .ThrowsAsync(CreateRpcException(StatusCode.InvalidArgument, "Field customUser:session_kind is not a valid dimension.")); var service = CreateService(); @@ -388,8 +388,8 @@ public async Task LiveData_Disabled_SkipsBothChecks() AssertStageOrder(result); Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.Realtime).Status); Assert.Equal(Statuses.Skipped, GetCheck(result, Stages.ProcessedData).Status); - _reportClientMock.Verify(x => x.RunRealtimeReportAsync(It.IsAny()), Times.Never); - _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny()), Times.Never); + _reportClientMock.Verify(x => x.RunRealtimeReportAsync(It.IsAny(), It.IsAny()), Times.Never); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] @@ -397,7 +397,7 @@ public async Task Realtime_HardFailure_FailsRealtimeOnlyProcessedStillRuns() { SetupHappyGooglePath(); _reportClientMock - .Setup(x => x.RunRealtimeReportAsync(It.IsAny())) + .Setup(x => x.RunRealtimeReportAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(CreateRpcException(StatusCode.Unavailable, "try again later")); var service = CreateService(); @@ -419,16 +419,16 @@ public async Task RunAsync_CalledTwiceWithTheSameArguments_ReadsGoogleEachTime() await service.RunAsync(StoreId, CreateRequest()); await service.RunAsync(StoreId, CreateRequest()); - _reportClientMock.Verify(x => x.GetMetadataAsync(It.IsAny()), Times.Exactly(2)); - _reportClientMock.Verify(x => x.RunRealtimeReportAsync(It.IsAny()), Times.Exactly(2)); - _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny()), Times.Exactly(2)); + _reportClientMock.Verify(x => x.GetMetadataAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + _reportClientMock.Verify(x => x.RunRealtimeReportAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); } private async Task RunApiAccessFailureAsync(RpcException exception) { SetupGoogleSettings(); _reportClientMock - .Setup(x => x.GetMetadataAsync(It.IsAny())) + .Setup(x => x.GetMetadataAsync(It.IsAny(), It.IsAny())) .ThrowsAsync(exception); var service = CreateService(); @@ -478,6 +478,27 @@ private AnalyticsDiagnosticsService CreateService() return new AnalyticsDiagnosticsService(_settingsResolverMock.Object, _reportClientMock.Object); } + [Fact] + public async Task RunAsync_EveryGoogleCallCarriesTheStoresRequestTimeout() + { + SetupHappyGooglePath(); + SetupSettings(new AnalyticsDataApiSettings { PropertyId = PropertyId, RequestTimeoutSeconds = 7 }); + var service = CreateService(); + + await service.RunAsync(StoreId, CreateRequest()); + + var timeout = TimeSpan.FromSeconds(7); + _reportClientMock.Verify(x => x.GetMetadataAsync(PropertyId, timeout), Times.AtLeastOnce); + _reportClientMock.Verify(x => x.CheckCompatibilityAsync(It.IsAny(), timeout), Times.AtLeastOnce); + _reportClientMock.Verify(x => x.RunRealtimeReportAsync(It.IsAny(), timeout), Times.AtLeastOnce); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny(), timeout), Times.AtLeastOnce); + + _reportClientMock.Verify(x => x.GetMetadataAsync(It.IsAny(), It.Is(t => t != timeout)), Times.Never); + _reportClientMock.Verify(x => x.CheckCompatibilityAsync(It.IsAny(), It.Is(t => t != timeout)), Times.Never); + _reportClientMock.Verify(x => x.RunRealtimeReportAsync(It.IsAny(), It.Is(t => t != timeout)), Times.Never); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny(), It.Is(t => t != timeout)), Times.Never); + } + private void SetupHappyGooglePath() { SetupGoogleSettings(); @@ -508,30 +529,30 @@ private void SetupMetadata(params string[] customDimensionApiNames) } _reportClientMock - .Setup(x => x.GetMetadataAsync(PropertyId)) + .Setup(x => x.GetMetadataAsync(PropertyId, It.IsAny())) .ReturnsAsync(metadata); } private void SetupCompatibility(CheckCompatibilityResponse response) { _reportClientMock - .Setup(x => x.CheckCompatibilityAsync(It.IsAny())) - .Callback((CheckCompatibilityRequest request) => _capturedCompatibilityRequests.Add(request)) + .Setup(x => x.CheckCompatibilityAsync(It.IsAny(), It.IsAny())) + .Callback((CheckCompatibilityRequest request, TimeSpan _) => _capturedCompatibilityRequests.Add(request)) .ReturnsAsync(response); } private void SetupRealtime(RunRealtimeReportResponse response) { _reportClientMock - .Setup(x => x.RunRealtimeReportAsync(It.IsAny())) - .Callback((RunRealtimeReportRequest request) => _capturedRealtimeRequests.Add(request)) + .Setup(x => x.RunRealtimeReportAsync(It.IsAny(), It.IsAny())) + .Callback((RunRealtimeReportRequest request, TimeSpan _) => _capturedRealtimeRequests.Add(request)) .ReturnsAsync(response); } private void SetupReport(RunReportResponse response) { _reportClientMock - .Setup(x => x.RunReportAsync(It.IsAny())) + .Setup(x => x.RunReportAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(response); } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index affe056..c732d87 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -529,6 +529,37 @@ private AnalyticsService CreateGoogleConfiguredService(TimeSpan? failureCacheTtl return CreateService(new AnalyticsDataApiSettings { PropertyId = PropertyId }, failureCacheTtl, cacheEnabled); } + [Fact] + public async Task SearchEventsAsync_ReadCarriesTheStoresRequestTimeout() + { + AnalyticsDataQuery query = null; + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .Callback(x => query = x) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateService(new AnalyticsDataApiSettings { PropertyId = PropertyId, RequestTimeoutSeconds = 12 }); + + await service.SearchEventsAsync(CreateSearchCriteria()); + + Assert.Equal(TimeSpan.FromSeconds(12), query.RequestTimeout); + } + + [Fact] + public async Task GetEventSummariesAsync_EveryReadCarriesTheStoresRequestTimeout() + { + var queries = new ConcurrentQueue(); + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .Callback(queries.Enqueue) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + var service = CreateService(new AnalyticsDataApiSettings { PropertyId = PropertyId, RequestTimeoutSeconds = 12 }); + + await service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Search)); + + Assert.NotEmpty(queries); + Assert.All(queries, x => Assert.Equal(TimeSpan.FromSeconds(12), x.RequestTimeout)); + } + // The request only carries dates, so these are one Google query — and the key decides whether it runs twice. [Fact] public async Task SearchEventsAsync_CriteriaDifferingOnlyInTimeOfDay_UsesCache() diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs index 5aa90e9..a1d5ae8 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsSettingsResolverTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -105,6 +106,53 @@ public async Task ResolveAsync_MalformedStoreValue_FallsBackToGlobal() Assert.Equal(ModuleConstants.Settings.DataApi.DefaultCacheTtlMinutes, settings.CacheTtlMinutes); } + [Fact] + public async Task ResolveAsync_RequestTimeout_StoreValueWinsOverGlobal() + { + SetupGlobalSetting(ModuleConstants.Settings.DataApi.RequestTimeoutSeconds.Name, 30); + SetupStore(CreateStore((ModuleConstants.Settings.DataApi.RequestTimeoutSeconds.Name, 20))); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(StoreId); + + Assert.Equal(TimeSpan.FromSeconds(20), settings.RequestTimeout); + } + + [Fact] + public async Task ResolveAsync_RequestTimeout_FallsBackToGlobal() + { + SetupGlobalSetting(ModuleConstants.Settings.DataApi.RequestTimeoutSeconds.Name, 30); + SetupStore(CreateStore()); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(StoreId); + + Assert.Equal(TimeSpan.FromSeconds(30), settings.RequestTimeout); + } + + [Fact] + public async Task ResolveAsync_RequestTimeout_DefaultsTo45Seconds() + { + SetupStore(CreateStore()); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(StoreId); + + Assert.Equal(TimeSpan.FromSeconds(45), settings.RequestTimeout); + } + + // Zero would mean no deadline at all: a call Google could hold for as long as it likes. + [Fact] + public async Task ResolveAsync_NonPositiveRequestTimeout_UsesTheDefault() + { + SetupStore(CreateStore((ModuleConstants.Settings.DataApi.RequestTimeoutSeconds.Name, 0))); + var resolver = CreateResolver(); + + var settings = await resolver.ResolveAsync(StoreId); + + Assert.Equal(TimeSpan.FromSeconds(ModuleConstants.Settings.DataApi.DefaultRequestTimeoutSeconds), settings.RequestTimeout); + } + [Fact] public async Task ResolveAsync_NullStoreId_UsesGlobalSettingsWithoutLoadingStore() { diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs index 30243e7..d7c7cf7 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs @@ -26,6 +26,28 @@ public class GoogleAnalyticsDataSourceTests private readonly Mock _reportClientMock = new(); private RunReportRequest _capturedRequest; + private TimeSpan? _capturedTimeout; + + // Both report shapes: an item-scoped read goes down its own path and must not drop the deadline on the way. + [Theory] + [InlineData(ModuleConstants.Dimensions.SearchTerm)] + [InlineData(ModuleConstants.Dimensions.ItemId)] + public async Task GetRowsAsync_SendsTheQueryTimeoutWithTheRequest(string dimensionName) + { + var dataSource = CreateDataSource(); + var query = new AnalyticsDataQuery + { + PropertyId = "123456", + EventNames = new List { ModuleConstants.EventNames.ViewItem }, + DimensionNames = new List { dimensionName }, + Take = 5, + RequestTimeout = TimeSpan.FromSeconds(12), + }; + + await dataSource.GetRowsAsync(query); + + Assert.Equal(TimeSpan.FromSeconds(12), _capturedTimeout); + } [Fact] public async Task GetRowsAsync_BuildsRunReportRequest() @@ -311,7 +333,7 @@ public async Task GetRowsAsync_ItemRows_MultipleEventNames_Throws() }; await Assert.ThrowsAsync(() => dataSource.GetRowsAsync(query)); - _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny()), Times.Never); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny(), It.IsAny()), Times.Never); } // GA4 reports dateHour in the PROPERTY's timezone and ships that zone with the response. @@ -353,7 +375,7 @@ public async Task GetRowsAsync_DimensionFilterWithoutValues_Throws() var exception = await Assert.ThrowsAsync(() => dataSource.GetRowsAsync(query)); Assert.Contains(ModuleConstants.UserDimensions.OrganizationId, exception.Message); - _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny()), Times.Never); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny(), It.IsAny()), Times.Never); } // Same widening as a filter without values, from the other end. @@ -372,7 +394,7 @@ public async Task GetRowsAsync_DimensionFilterWithoutName_Throws(string dimensio }; await Assert.ThrowsAsync(() => dataSource.GetRowsAsync(query)); - _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny()), Times.Never); + _reportClientMock.Verify(x => x.RunReportAsync(It.IsAny(), It.IsAny()), Times.Never); } private static AnalyticsDataQuery CreateDateHourQuery() @@ -407,8 +429,12 @@ private static RunReportResponse CreateDateHourResponse(string dateHour, string private GoogleAnalyticsDataSource CreateDataSource(RunReportResponse response = null) { _reportClientMock - .Setup(x => x.RunReportAsync(It.IsAny())) - .Callback((RunReportRequest request) => _capturedRequest = request) + .Setup(x => x.RunReportAsync(It.IsAny(), It.IsAny())) + .Callback((RunReportRequest request, TimeSpan timeout) => + { + _capturedRequest = request; + _capturedTimeout = timeout; + }) .ReturnsAsync(response ?? new RunReportResponse()); return new GoogleAnalyticsDataSource(_reportClientMock.Object, NullLogger.Instance); diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs index f5f2a3e..345313b 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsReportClientTests.cs @@ -1,7 +1,10 @@ using System; using System.Security.Cryptography; using System.Threading.Tasks; +using Google.Analytics.Data.V1Beta; +using Google.Api.Gax.Grpc; using Google.Apis.Auth.OAuth2; +using Moq; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; using Xunit; @@ -63,6 +66,23 @@ public async Task GetCredentialAsync_AfterASuccessfulResolve_IsCached() Assert.Equal(1, client.AdcCallCount); } + // The library's own default is 60 seconds, never retried; the store's deadline has to reach the call itself. + [Fact] + public async Task RunReportAsync_SendsTheTimeoutAsTheCallDeadline() + { + CallSettings callSettings = null; + var dataClientMock = new Mock(); + dataClientMock + .Setup(x => x.RunReportAsync(It.IsAny(), It.IsAny())) + .Callback((RunReportRequest _, CallSettings settings) => callSettings = settings) + .ReturnsAsync(new RunReportResponse()); + var client = new TestableReportClient(CreateUnscopedCredential()) { DataClient = dataClientMock.Object }; + + await client.RunReportAsync(new RunReportRequest(), TimeSpan.FromSeconds(45)); + + Assert.Equal(TimeSpan.FromSeconds(45), callSettings.Expiration.Timeout); + } + private static GoogleCredential CreateUnscopedCredential() { using var rsa = RSA.Create(PrivateKeySizeInBits); @@ -89,6 +109,8 @@ public TestableReportClient(GoogleCredential applicationDefaultCredential) public bool FailNextAdcCall { get; set; } + public BetaAnalyticsDataClient DataClient { get; set; } + public Task ResolveAsync() { return ResolveCredentialAsync(); @@ -100,6 +122,11 @@ public Task GetCachedCredentialAsync() return GetCredentialAsync(); } + protected override Task GetClientAsync() + { + return DataClient != null ? Task.FromResult(DataClient) : base.GetClientAsync(); + } + protected override Task GetApplicationDefaultCredentialAsync() { AdcCallCount++; From fb3c7aa58aa2f154a54e38744b79b0e1fa7faaf9 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Fri, 9 Oct 2026 15:47:52 -0600 Subject: [PATCH 32/36] Return the event total beside the row count Every report now asks Google for the metric total (metricAggregations: TOTAL), mapped to AnalyticsEventSearchResult.TotalEventCount: the metric summed over every matching row, whatever page was requested. TotalCount stays the row count a pager needs; a consumer that wants to say how many searches or views happened no longer has to count hour rows. Co-Authored-By: Claude Opus 5.5 --- docs/index.md | 4 ++ .../Models/AnalyticsEventSearchResult.cs | 3 ++ .../Services/GoogleAnalyticsDataSource.cs | 12 ++++++ .../GoogleAnalyticsDataSourceTests.cs | 37 +++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/docs/index.md b/docs/index.md index 3690a86..cd3a92f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -96,6 +96,10 @@ Besides tagging, the module can **read** GA4 through the Data API (`runReport`) `IAnalyticsService` (events, dimensions, filters and date ranges — no domain concepts). It has no GraphQL surface; consumers build their own fields on top of it. +A search answers two different counts, both for everything it matched rather than for the page it returned: +`TotalCount` is the number of **rows** (what a pager needs), and `TotalEventCount` is the metric summed over all of +them — events, or item views for an item-scoped read — so a row that stands for three searches counts three. + ### Prerequisites 1. Set **GoogleAnalytics4.DataApi.PropertyId** for the store. diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs index 39a8c94..26e7fb0 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/Models/AnalyticsEventSearchResult.cs @@ -9,6 +9,9 @@ public class AnalyticsEventSearchResult : ICloneable { public int TotalCount { get; set; } + // Every matching row's Count summed, not only the returned page's: TotalCount counts rows, this counts events. + public int TotalEventCount { get; set; } + public IList Events { get; set; } = []; public virtual object Clone() diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs index 6c30766..a442e87 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs @@ -104,6 +104,9 @@ protected virtual RunReportRequest CreateRequest(AnalyticsDataQuery query) Offset = query.Skip, }; + // Totalled by Google over every matching row, so the event count needs no second read and ignores paging. + request.MetricAggregations.Add(MetricAggregation.Total); + request.DateRanges.Add(new DateRange { StartDate = query.From?.ToString(DateFormat, CultureInfo.InvariantCulture) ?? DefaultStartDate, @@ -171,6 +174,7 @@ protected virtual AnalyticsEventSearchResult MapResponse(RunReportResponse respo { var result = AbstractTypeFactory.TryCreateInstance(); result.TotalCount = response.RowCount; + result.TotalEventCount = ParseMetricTotal(response); if (query.Take <= 0) { @@ -200,6 +204,14 @@ protected virtual AnalyticsEventSearchResult MapResponse(RunReportResponse respo return result; } + // A read that matched nothing may come back without a totals row at all, which is a total of 0. + protected virtual int ParseMetricTotal(RunReportResponse response) + { + var total = response.Totals.FirstOrDefault()?.MetricValues.FirstOrDefault()?.Value; + + return int.TryParse(total, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : 0; + } + protected virtual void MapDimensionValue(AnalyticsEvent analyticsEvent, string dimensionName, string value, TimeZoneInfo timeZone) { if (string.IsNullOrEmpty(value) || value == NotSetValue) diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs index d7c7cf7..31baf90 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs @@ -49,6 +49,43 @@ public async Task GetRowsAsync_SendsTheQueryTimeoutWithTheRequest(string dimensi Assert.Equal(TimeSpan.FromSeconds(12), _capturedTimeout); } + [Fact] + public async Task GetRowsAsync_AsksGoogleForTheMetricTotal() + { + var dataSource = CreateDataSource(); + + await dataSource.GetRowsAsync(new AnalyticsDataQuery { PropertyId = "123456", Take = 5 }); + + Assert.Contains(MetricAggregation.Total, _capturedRequest.MetricAggregations); + } + + // The count-only read is the one a badge makes, and it maps nothing else — the total must not be skipped with + // the rows. + [Theory] + [InlineData(0)] + [InlineData(20)] + public async Task GetRowsAsync_MapsTheMetricTotalBesideTheRowCount(int take) + { + var response = new RunReportResponse { RowCount = 3 }; + response.Totals.Add(new Row { MetricValues = { new MetricValue { Value = "17" } } }); + var dataSource = CreateDataSource(response); + + var result = await dataSource.GetRowsAsync(new AnalyticsDataQuery { PropertyId = "123456", Take = take }); + + Assert.Equal(3, result.TotalCount); + Assert.Equal(17, result.TotalEventCount); + } + + [Fact] + public async Task GetRowsAsync_NoTotalsRow_CountsZeroEvents() + { + var dataSource = CreateDataSource(new RunReportResponse()); + + var result = await dataSource.GetRowsAsync(new AnalyticsDataQuery { PropertyId = "123456", Take = 0 }); + + Assert.Equal(0, result.TotalEventCount); + } + [Fact] public async Task GetRowsAsync_BuildsRunReportRequest() { From 02e2d474d0905fbeb568e32471c6c693f5ddd170 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Fri, 9 Oct 2026 15:51:36 -0600 Subject: [PATCH 33/36] Read a named summary in one round A summary made a totals read first and only then the newest-bucket probes, so its worst case was two request timeouts back to back. Each name is now one read that returns the newest hour bucket with Google's total over every bucket beside it; the reads of a named summary need nothing from each other and start together. Only a summary that names no events still reads which events exist first. Co-Authored-By: Claude Opus 5.5 --- docs/index.md | 13 +- .../Services/AnalyticsService.cs | 96 +++++------- .../AnalyticsServiceTests.cs | 148 +++++++++++------- .../GoogleAnalyticsDataSourceTests.cs | 4 +- 4 files changed, 143 insertions(+), 118 deletions(-) diff --git a/docs/index.md b/docs/index.md index cd3a92f..67307a4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -138,11 +138,14 @@ The exception names the store and the operation and nothing else. The property i response stay in this module's log, because a consumer cannot know how far its own error surface travels; run `POST api/googleanalytics/{storeId}/diagnostics` to see the cause. -A summary read fans out — one totals read plus a newest-occurrence probe per event name — and **any** of -them failing fails the whole summary. An earlier design isolated a failed probe and returned the totals with a -null `LastOccurredAt`; that was reversed deliberately, because a null there reads as "this event never happened" -and a consumer presents it as fact. The cost of the current shape is narrower but real: one transient probe -failure costs the whole summary until the failure entry expires, where before it cost a single field. +A summary read fans out — one read per event name, returning the newest hour bucket with Google's total over every +bucket beside it. Named reads need nothing from each other, so they start together (up to four at a time) and a +summary takes one request timeout, not several in a row; only a summary that names no events first reads which +events exist. **Any** of the reads failing fails the whole summary. An earlier design isolated a failed read and +returned the totals with a null `LastOccurredAt`; that was reversed deliberately, because a null there reads as +"this event never happened" and a consumer presents it as fact. The cost of the current shape is narrower but real: +one transient read failure costs the whole summary until the failure entry expires, where before it cost a single +field. Only the third step is cached — a failure included, for a fixed 60 seconds, so a misconfigured property cannot burn quota on a hot page. The criteria and the configuration are re-checked on every call, so a store configured diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 3e6fbe6..34289f8 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -17,14 +17,14 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; public class AnalyticsService : IAnalyticsService { // Count mode returns one row per event name, so a summary naming none is bounded by this — and so is the - // probe fan-out below. A summary over hundreds of names is a reporting question, not a feature read. + // per-name fan-out below. A summary over hundreds of names is a reporting question, not a feature read. private const int MaxEventNames = 50; // Date mode orders by dateHour descending, so the newest bucket is the first row. - private const int LatestBucketProbeSize = 1; + private const int LatestBucketSize = 1; - // The probes are independent, so they run together — capped, because GA4 limits concurrent requests. - private const int MaxProbeConcurrency = 4; + // The per-name reads are independent, so they run together — capped, because GA4 limits concurrent requests. + private const int MaxConcurrentReads = 4; // How the platform expresses Caching:CacheEnabled=false on the options it hands the factory. private static readonly TimeSpan CacheDisabled = TimeSpan.FromTicks(1); @@ -241,31 +241,46 @@ private static bool IsMidnightOrAbsent(DateTime? value) } // GA has no "max(dateHour)", so reducing a fetched series would transfer one row per event name PER HOUR to - // produce two numbers. Two narrow reads answer it instead: 'count' mode for the totals, then a one-row 'date' - // probe per name that has any. + // produce two numbers. One narrow read per name answers both instead, and named reads need nothing first, so + // they start together: no read of a named summary waits for another. Only an unnamed one must find its names. protected virtual async Task> CreateSummariesAsync( AnalyticsDataApiSettings settings, AnalyticsEventSummaryCriteria criteria) { - var totalsQuery = CreateQuery(settings, criteria); - totalsQuery.SortBy = ModuleConstants.SortBy.Count; - totalsQuery.Take = criteria.EventNames.IsNullOrEmpty() ? MaxEventNames : criteria.EventNames.Count; + var eventNames = criteria.EventNames.IsNullOrEmpty() + ? await GetEventNamesAsync(settings, criteria) + : criteria.EventNames; - var totals = await _dataSource.GetRowsAsync(totalsQuery); + var summaries = new AnalyticsEventSummary[eventNames.Count]; - // Count-mode rows carry no date, so the summaries come back with a null LastOccurredAt that the probe fills. - var summaries = CreateSummaries(criteria, totals.Events); - - // A failed probe fails the whole read: a null LastOccurredAt reads as "never happened". + // A failed read fails the whole summary: a null LastOccurredAt reads as "never happened". await Parallel.ForEachAsync( - summaries.Where(x => x.TotalCount > 0), - new ParallelOptions { MaxDegreeOfParallelism = MaxProbeConcurrency }, - async (summary, _) => summary.LastOccurredAt = await GetLastOccurredAtAsync(settings, criteria, summary.EventName)); + Enumerable.Range(0, eventNames.Count), + new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentReads }, + async (index, _) => summaries[index] = await GetSummaryAsync(settings, criteria, eventNames[index])); return summaries; } - protected virtual async Task GetLastOccurredAtAsync( + protected virtual async Task> GetEventNamesAsync( + AnalyticsDataApiSettings settings, + AnalyticsEventSummaryCriteria criteria) + { + var query = CreateQuery(settings, criteria); + query.SortBy = ModuleConstants.SortBy.Count; + query.Take = MaxEventNames; + + var rows = await _dataSource.GetRowsAsync(query); + + return rows.Events + .Select(x => x.EventName) + .Where(x => !string.IsNullOrEmpty(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + // The newest bucket answers "when", and the total Google sums over every bucket answers "how many". + protected virtual async Task GetSummaryAsync( AnalyticsDataApiSettings settings, AnalyticsEventSummaryCriteria criteria, string eventName) @@ -273,11 +288,18 @@ await Parallel.ForEachAsync( var query = CreateQuery(settings, criteria); query.EventNames = [eventName]; query.SortBy = ModuleConstants.SortBy.Date; - query.Take = LatestBucketProbeSize; + query.Take = LatestBucketSize; var rows = await _dataSource.GetRowsAsync(query); - return rows.Events.Where(x => x.EventName.EqualsIgnoreCase(eventName)).Max(x => x.OccurredAt); + var result = AbstractTypeFactory.TryCreateInstance(); + result.EventName = eventName; + result.TotalCount = rows.TotalEventCount; + result.LastOccurredAt = result.TotalCount > 0 + ? rows.Events.Where(x => x.EventName.EqualsIgnoreCase(eventName)).Max(x => x.OccurredAt) + : null; + + return result; } protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settings, AnalyticsEventCriteriaBase criteria) @@ -294,40 +316,6 @@ protected virtual AnalyticsDataQuery CreateQuery(AnalyticsDataApiSettings settin return query; } - protected virtual IList CreateSummaries(AnalyticsEventSummaryCriteria criteria, IList events) - { - var aggregates = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var analyticsEvent in events.Where(x => !string.IsNullOrEmpty(x.EventName))) - { - aggregates.TryGetValue(analyticsEvent.EventName, out var aggregate); - - aggregates[analyticsEvent.EventName] = ( - aggregate.TotalCount + analyticsEvent.Count, - aggregate.LastOccurredAt == null || analyticsEvent.OccurredAt > aggregate.LastOccurredAt - ? analyticsEvent.OccurredAt - : aggregate.LastOccurredAt); - } - - var eventNames = criteria.EventNames.IsNullOrEmpty() ? aggregates.Keys.ToList() : criteria.EventNames; - - return eventNames - .Select(eventName => - { - var summary = AbstractTypeFactory.TryCreateInstance(); - summary.EventName = eventName; - - if (aggregates.TryGetValue(eventName, out var aggregate)) - { - summary.TotalCount = aggregate.TotalCount; - summary.LastOccurredAt = aggregate.LastOccurredAt; - } - - return summary; - }) - .ToList(); - } - protected virtual TimeSpan GetCacheTtl(AnalyticsDataApiSettings settings) { return TimeSpan.FromMinutes(settings.CacheTtlMinutes); diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index c732d87..3deb2a2 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -372,55 +373,39 @@ public async Task SearchEventsAsync_ShorterSlidingDefault_DoesNotEvictBeforeTheC } [Fact] - public async Task GetEventSummariesAsync_AggregatesPerRequestedEventName() + public async Task GetEventSummariesAsync_ReportsEachNamesTotalAndNewestBucket() { - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult( - ("search", To.AddHours(-2), 2), - ("search", To, 3), - ("login", To.AddHours(-1), 5))); + SetupSummaryRead(ModuleConstants.EventNames.Search, total: 5, newest: To); + SetupSummaryRead(ModuleConstants.EventNames.Login, total: 7, newest: To.AddHours(-1)); + SetupSummaryRead(ModuleConstants.EventNames.SignUp, total: 0, newest: null); var service = CreateGoogleConfiguredService(); - var criteria = CreateSummaryCriteria( + var summaries = await service.GetEventSummariesAsync(CreateSummaryCriteria( ModuleConstants.EventNames.Search, ModuleConstants.EventNames.Login, - ModuleConstants.EventNames.SignUp); - var summaries = await service.GetEventSummariesAsync(criteria); - - Assert.Equal(3, summaries.Count); - - var search = summaries.First(x => x.EventName == ModuleConstants.EventNames.Search); - Assert.Equal(5, search.TotalCount); - Assert.Equal(To, search.LastOccurredAt); + ModuleConstants.EventNames.SignUp)); - var login = summaries.First(x => x.EventName == ModuleConstants.EventNames.Login); - Assert.Equal(5, login.TotalCount); - Assert.Equal(To.AddHours(-1), login.LastOccurredAt); + // In the order asked for, although the reads finish in any order. + Assert.Equal( + [ModuleConstants.EventNames.Search, ModuleConstants.EventNames.Login, ModuleConstants.EventNames.SignUp], + summaries.Select(x => x.EventName)); - var signUp = summaries.First(x => x.EventName == ModuleConstants.EventNames.SignUp); - Assert.Equal(0, signUp.TotalCount); - Assert.Null(signUp.LastOccurredAt); + Assert.Equal(5, summaries[0].TotalCount); + Assert.Equal(To, summaries[0].LastOccurredAt); + Assert.Equal(7, summaries[1].TotalCount); + Assert.Equal(To.AddHours(-1), summaries[1].LastOccurredAt); + Assert.Equal(0, summaries[2].TotalCount); + Assert.Null(summaries[2].LastOccurredAt); } - // Keeping the totals would answer "when did this last happen?" with a null that reads as "never". + // Keeping the other names would answer "when did this last happen?" with a null that reads as "never". [Fact] - public async Task GetEventSummariesAsync_OneProbeFails_ThrowsRatherThanReportNever() + public async Task GetEventSummariesAsync_OneReadFails_ThrowsRatherThanReportNever() { - var totals = CreateCountModeResult(("search", 3), ("login", 5)); - + SetupSummaryRead(ModuleConstants.EventNames.Search, total: 3, newest: To); _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.Is(q => q.SortBy == ModuleConstants.SortBy.Count))) - .ReturnsAsync(totals); - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.Is(q => - q.SortBy != ModuleConstants.SortBy.Count && q.EventNames.Contains(ModuleConstants.EventNames.Login)))) + .Setup(x => x.GetRowsAsync(It.Is(q => q.EventNames.Contains(ModuleConstants.EventNames.Login)))) .ThrowsAsync(new InvalidOperationException("GA responded 429")); - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.Is(q => - q.SortBy != ModuleConstants.SortBy.Count && q.EventNames.Contains(ModuleConstants.EventNames.Search)))) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); - var service = CreateGoogleConfiguredService(); await Assert.ThrowsAsync(() => service.GetEventSummariesAsync( @@ -430,15 +415,11 @@ await Assert.ThrowsAsync(() => service.GetEventSummariesAsyn [Fact] public async Task GetEventSummariesAsync_ReadsNarrowly_NeverTheWholeHourlySeries() { - // Concurrent, because the probes run in parallel; enqueue order still puts the awaited totals read first. var queries = new ConcurrentQueue(); _googleDataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) .Callback(queries.Enqueue) - .ReturnsAsync(CreateSearchResult( - ("search", To.AddHours(-2), 2), - ("search", To, 3), - ("login", To.AddHours(-1), 5))); + .ReturnsAsync(new AnalyticsEventSearchResult()); var service = CreateGoogleConfiguredService(); var criteria = CreateSummaryCriteria( @@ -447,26 +428,63 @@ public async Task GetEventSummariesAsync_ReadsNarrowly_NeverTheWholeHourlySeries ModuleConstants.EventNames.SignUp); await service.GetEventSummariesAsync(criteria); - // One totals read for all three names, then a newest-bucket probe only for the two that have events: - // sign_up totals zero, so nothing is asked about its last occurrence. + // One newest-bucket read per name, carrying the name's total; no separate totals read. var reads = queries.ToList(); Assert.Equal(3, reads.Count); - - var totals = reads[0]; - Assert.Equal(ModuleConstants.SortBy.Count, totals.SortBy); - Assert.Equal(3, totals.Take); - Assert.Equal(criteria.EventNames, totals.EventNames); - - var probes = reads.Skip(1).ToList(); - Assert.All(probes, x => Assert.Equal(ModuleConstants.SortBy.Date, x.SortBy)); - Assert.All(probes, x => Assert.Equal(1, x.Take)); - // Unordered: the probes run concurrently. + Assert.All(reads, x => Assert.Equal(ModuleConstants.SortBy.Date, x.SortBy)); Assert.Equal( - [ModuleConstants.EventNames.Login, ModuleConstants.EventNames.Search], - probes.Select(x => Assert.Single(x.EventNames)).OrderBy(x => x, StringComparer.Ordinal)); + criteria.EventNames.OrderBy(x => x, StringComparer.Ordinal), + reads.Select(x => Assert.Single(x.EventNames)).OrderBy(x => x, StringComparer.Ordinal)); // The point of the shape: no read is proportional to the number of hours in the range. - Assert.All(reads, x => Assert.True(x.Take <= 3)); + Assert.All(reads, x => Assert.Equal(1, x.Take)); + } + + // The reads of a named summary need nothing from each other, so none may wait for another: two in a row is two + // timeouts in a row for the consumer's whole request. + [Fact] + public async Task GetEventSummariesAsync_NamedReads_StartTogether() + { + var started = 0; + var allStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .Returns(async () => + { + if (Interlocked.Increment(ref started) == 3) + { + allStarted.SetResult(); + } + + // A read that waited for another would never see the third one start. + await allStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + return new AnalyticsEventSearchResult(); + }); + var service = CreateGoogleConfiguredService(); + + await service.GetEventSummariesAsync(CreateSummaryCriteria( + ModuleConstants.EventNames.Search, + ModuleConstants.EventNames.Login, + ModuleConstants.EventNames.SignUp)); + + Assert.Equal(3, started); + } + + [Fact] + public async Task GetEventSummariesAsync_NoNames_FindsTheNamesFirstThenReadsEach() + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.Is(q => q.SortBy == ModuleConstants.SortBy.Count))) + .ReturnsAsync(CreateCountModeResult(("search", 3), ("login", 5))); + SetupSummaryRead(ModuleConstants.EventNames.Search, total: 3, newest: To); + SetupSummaryRead(ModuleConstants.EventNames.Login, total: 5, newest: To.AddHours(-1)); + var service = CreateGoogleConfiguredService(); + + var summaries = await service.GetEventSummariesAsync(CreateSummaryCriteria()); + + Assert.Equal([ModuleConstants.EventNames.Search, ModuleConstants.EventNames.Login], summaries.Select(x => x.EventName)); + Assert.Equal(5, summaries[1].TotalCount); + Assert.Equal(To.AddHours(-1), summaries[1].LastOccurredAt); } [Fact] @@ -688,7 +706,23 @@ private static AnalyticsEventSummaryCriteria CreateSummaryCriteria(params string }; } - // Count mode carries no date; the probes are what fill it in. + // A summary's per-name read: Google's total over every bucket, and only the newest bucket as a row. + private void SetupSummaryRead(string eventName, int total, DateTime? newest) + { + _googleDataSourceMock + .Setup(x => x.GetRowsAsync(It.Is(q => + q.SortBy == ModuleConstants.SortBy.Date && q.EventNames.Contains(eventName)))) + .ReturnsAsync(new AnalyticsEventSearchResult + { + TotalCount = newest == null ? 0 : 4, + TotalEventCount = total, + Events = newest == null + ? [] + : [new AnalyticsEvent { EventName = eventName, OccurredAt = newest, Count = 2 }], + }); + } + + // Count mode carries no date: an unnamed summary takes only its event names from it. private static AnalyticsEventSearchResult CreateCountModeResult(params (string EventName, int Count)[] events) { return new AnalyticsEventSearchResult diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs index 31baf90..dab0248 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsDataSourceTests.cs @@ -354,8 +354,8 @@ public async Task GetRowsAsync_ItemRows_SingleEventName_FillsEventNameFromCriter Assert.Equal("SKU-001", analyticsEvent.Dimensions["itemId"]); } - // Item rows carry no event name, so two requested names would come back unattributable — and through - // CreateSummaries as zero counts indistinguishable from no activity. + // Item rows carry no event name, so two requested names would come back unattributable — and a summary would + // report them as zero counts, indistinguishable from no activity. [Fact] public async Task GetRowsAsync_ItemRows_MultipleEventNames_Throws() { From de1a54d499bb6b328034c268ec106a0257d14902 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Tue, 13 Oct 2026 09:35:15 -0600 Subject: [PATCH 34/36] Simplify the analytics read code A cleanup pass with no change in behaviour. The settings failure path, the item-scope refusal and metric parsing each live in one place now. A store setting value converts through the platform's SettingValueConverter.TryConvert, with one fallback to the global value. GA4 resource names come from Google's PropertyName and MetadataName, and diagnostics prefix user dimensions through their own MapDimensionName seam, as their requests' dimensions already did. Diagnostics messages take the setting name, the scope and the time windows from the values that define them. A summary's last hour is its one row's. The service tests mock IAnalyticsDataSource and share one row setup, and the controller test uses Moq instead of two fakes. Co-Authored-By: Claude Opus 5.5 --- .../Services/AnalyticsDiagnosticsService.cs | 40 +++--- .../Services/AnalyticsFilterBuilder.cs | 29 +++- .../Services/AnalyticsService.cs | 51 +++---- .../Services/AnalyticsSettingsResolver.cs | 24 ++-- .../Services/GoogleAnalyticsDataSource.cs | 28 ++-- .../Services/GoogleAnalyticsReportClient.cs | 6 +- .../AnalyticsDiagnosticsServiceTests.cs | 2 - .../AnalyticsFilterBuilderTests.cs | 11 +- .../AnalyticsServiceTests.cs | 132 +++++++----------- .../GoogleAnalyticsControllerTests.cs | 38 +---- 10 files changed, 147 insertions(+), 214 deletions(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs index 96f3066..33f6a9d 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsDiagnosticsService.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading.Tasks; using Google.Analytics.Data.V1Beta; @@ -22,9 +21,9 @@ public class AnalyticsDiagnosticsService : IAnalyticsDiagnosticsService { private const string ApplicationDefaultCredentials = "Application Default Credentials"; private const string ProbeFilterValue = "diagnostics-probe"; - private const string ProcessedDataStartDate = "7daysAgo"; + private const int ProcessedDataDays = 7; private const string ProcessedDataEndDate = "today"; - private const int RealtimeMinutesAgo = 29; + private const int RealtimeWindowMinutes = 30; private const int LiveDataRowsLimit = 50; private const string ScopeInsufficientReason = "ACCESS_TOKEN_SCOPE_INSUFFICIENT"; @@ -93,7 +92,7 @@ protected virtual async Task CheckConfigurationAsync(s if (!settings.IsConfigured) { AddCheck(checks, Stages.Configuration, Statuses.Failed, - "GA4 Data API property id is not configured for the store (GoogleAnalytics4.DataApi.PropertyId)."); + $"GA4 Data API property id is not configured for the store ({ModuleConstants.Settings.DataApi.PropertyId.Name})."); return null; } @@ -156,7 +155,7 @@ protected virtual string MapApiAccessFailure(RpcException exception, string prop if (reason == ScopeInsufficientReason) { - return "The credential's access token lacks the required scope — re-authorize or re-issue it with the https://www.googleapis.com/auth/analytics.readonly scope."; + return $"The credential's access token lacks the required scope — re-authorize or re-issue it with the {GoogleAnalyticsReportClient.AnalyticsReadOnlyScope} scope."; } if (reason == ServiceDisabledReason) @@ -178,7 +177,7 @@ protected virtual string MapApiAccessFailure(RpcException exception, string prop if (exception.StatusCode == StatusCode.NotFound) { - return $"GA4 property {propertyId} was not found — check the GoogleAnalytics4.DataApi.PropertyId setting (use the numeric property id, not the measurement id)."; + return $"GA4 property {propertyId} was not found — check the {ModuleConstants.Settings.DataApi.PropertyId.Name} setting (use the numeric property id, not the measurement id)."; } return "Google Analytics Data API call failed."; @@ -198,7 +197,7 @@ protected virtual void CheckCustomDimensions(Metadata metadata, AnalyticsDiagnos .Where(x => !string.IsNullOrEmpty(x)) .ToHashSet(StringComparer.OrdinalIgnoreCase); var missingNames = requestedNames - .Where(x => !registeredNames.Contains(ModuleConstants.UserDimensions.Prefix + x)) + .Where(x => !registeredNames.Contains(MapDimensionName(x, requestedNames))) .ToList(); if (missingNames.Count == 0) @@ -304,12 +303,12 @@ protected virtual async Task CheckRealtimeAsync(AnalyticsDataApiSettings setting if (eventCounts.Count == 0) { - AddCheck(checks, Stages.Realtime, Statuses.Warning, "No events in the last 30 minutes." + fallbackNote, detail); + AddCheck(checks, Stages.Realtime, Statuses.Warning, $"No events in the last {RealtimeWindowMinutes} minutes.{fallbackNote}", detail); } else { AddCheck(checks, Stages.Realtime, GetDataStatus(missingNames), - $"Events in the last 30 minutes: {FormatEventCounts(eventCounts)}.{fallbackNote}{DescribeMissingRequestedEvents(missingNames)}", + $"Events in the last {RealtimeWindowMinutes} minutes: {FormatEventCounts(eventCounts)}.{fallbackNote}{DescribeMissingRequestedEvents(missingNames)}", detail); } } @@ -339,7 +338,7 @@ protected virtual async Task CheckProcessedDataAsync(AnalyticsDataApiSettings se var missingNames = GetMissingRequestedEvents(request, eventCounts); AddCheck(checks, Stages.ProcessedData, GetDataStatus(missingNames), - $"Processed events over the last 7 days: {FormatEventCounts(eventCounts)}.{DescribeMissingRequestedEvents(missingNames)}"); + $"Processed events over the last {ProcessedDataDays} days: {FormatEventCounts(eventCounts)}.{DescribeMissingRequestedEvents(missingNames)}"); } } catch (Exception ex) @@ -352,7 +351,7 @@ protected virtual CheckCompatibilityRequest BuildCompatibilityRequest(string pro { var request = new CheckCompatibilityRequest { - Property = AnalyticsFilterBuilder.PropertyName(propertyId), + Property = PropertyName.FormatProperty(propertyId), CompatibilityFilter = Compatibility.Incompatible, }; @@ -377,7 +376,7 @@ protected virtual CheckCompatibilityRequest BuildCompatibilityRequest(string pro } expressions.AddRange(userDimensionNames - .Select(x => AnalyticsFilterBuilder.CreateInListExpression(ModuleConstants.UserDimensions.Prefix + x, [ProbeFilterValue]))); + .Select(x => AnalyticsFilterBuilder.CreateInListExpression(MapDimensionName(x, userDimensionNames), [ProbeFilterValue]))); var dimensionFilter = AnalyticsFilterBuilder.Combine(expressions); if (dimensionFilter != null) @@ -392,18 +391,18 @@ protected virtual RunRealtimeReportRequest BuildRealtimeRequest(string propertyI { var request = new RunRealtimeReportRequest { - Property = AnalyticsFilterBuilder.PropertyName(propertyId), + Property = PropertyName.FormatProperty(propertyId), Limit = LiveDataRowsLimit, }; request.Dimensions.Add(new Dimension { Name = ModuleConstants.Dimensions.EventName }); foreach (var dimensionName in userDimensionNames) { - request.Dimensions.Add(new Dimension { Name = ModuleConstants.UserDimensions.Prefix + dimensionName }); + request.Dimensions.Add(new Dimension { Name = MapDimensionName(dimensionName, userDimensionNames) }); } request.Metrics.Add(new Metric { Name = ModuleConstants.Metrics.EventCount }); - request.MinuteRanges.Add(new MinuteRange { StartMinutesAgo = RealtimeMinutesAgo, EndMinutesAgo = 0 }); + request.MinuteRanges.Add(new MinuteRange { StartMinutesAgo = RealtimeWindowMinutes - 1, EndMinutesAgo = 0 }); return request; } @@ -412,11 +411,11 @@ protected virtual RunReportRequest BuildProcessedDataRequest(string propertyId) { var request = new RunReportRequest { - Property = AnalyticsFilterBuilder.PropertyName(propertyId), + Property = PropertyName.FormatProperty(propertyId), Limit = LiveDataRowsLimit, }; - request.DateRanges.Add(new DateRange { StartDate = ProcessedDataStartDate, EndDate = ProcessedDataEndDate }); + request.DateRanges.Add(new DateRange { StartDate = $"{ProcessedDataDays}daysAgo", EndDate = ProcessedDataEndDate }); request.Dimensions.Add(new Dimension { Name = ModuleConstants.Dimensions.EventName }); request.Metrics.Add(new Metric { Name = ModuleConstants.Metrics.EventCount }); @@ -488,10 +487,7 @@ protected virtual IList> AggregateEventCounts(IEnumer continue; } - var count = row.MetricValues.Count > 0 - && long.TryParse(row.MetricValues[0].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) - ? value - : 0; + var count = AnalyticsFilterBuilder.ParseMetric(row.MetricValues.FirstOrDefault()?.Value); counts[eventName] = counts.TryGetValue(eventName, out var existing) ? existing + count : count; } @@ -507,7 +503,7 @@ protected virtual IList GetMissingRequestedEvents(AnalyticsDiagnosticsRe { return (request.EventNames ?? []) .Where(x => !string.IsNullOrEmpty(x) && !eventCounts.Any(count => count.Key.EqualsIgnoreCase(x))) - .Distinct(StringComparer.OrdinalIgnoreCase) + .DistinctIgnoreCase() .ToList(); } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs index 2248f79..aea6072 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsFilterBuilder.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; +using System.Numerics; using Google.Analytics.Data.V1Beta; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; @@ -18,11 +20,6 @@ internal static class AnalyticsFilterBuilder ModuleConstants.Dimensions.ItemListName, ]; - public static string PropertyName(string propertyId) - { - return $"properties/{propertyId}"; - } - // Two callers on purpose: the service runs it before the cache, so a caller-caused refusal is never cached; // the data source runs it again as the last place that can refuse an unscoped read reaching Google. public static void ValidateDimensionFilters(IList filters, string paramName) @@ -53,6 +50,28 @@ public static bool HasItemDimensions(IList dimensionNames, IList ItemDimensionNames.Contains(x?.DimensionName)) == true; } + // Two callers, as for the dimension filters above. + public static void ValidateItemScope( + IList eventNames, + IList dimensionNames, + IList dimensionFilters, + string paramName) + { + if (eventNames?.Count > 1 && HasItemDimensions(dimensionNames, dimensionFilters)) + { + throw new ArgumentException( + "An item-scoped read cannot be narrowed to more than one event name: its rows carry no event name to tell them apart.", + paramName); + } + } + + // GA sends every metric value as text; a missing or unparsable one counts as zero. + public static T ParseMetric(string value) + where T : INumberBase + { + return T.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : T.Zero; + } + // Ordinal on purpose: GA4 field names are case-sensitive, so a mis-cased name is rejected by the API // whether or not it is prefixed, and the response echoes back the exact name that was sent. public static string MapDimensionName(string dimensionName, IList extraUserDimensionNames = null) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs index 34289f8..f6685ac 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsService.cs @@ -55,17 +55,7 @@ public AnalyticsService( // has not answered the question, so it throws — an outage must not read as "not configured". public virtual async Task IsConfiguredAsync(string storeId) { - AnalyticsDataApiSettings settings; - - try - { - settings = await _settingsResolver.ResolveAsync(storeId); - } - catch (Exception ex) - { - LogFailure(ConfigurationOperation, storeId, ex); - throw CreateReadException(ConfigurationOperation, storeId); - } + var settings = await ResolveOrThrowAsync(ConfigurationOperation, storeId); return settings.IsConfigured; } @@ -121,13 +111,7 @@ protected virtual void ValidateCriteria(AnalyticsEventCriteriaBase criteria) // Only a search carries extra dimension names; a summary can still reach the item scope through a filter. var dimensionNames = (criteria as AnalyticsEventSearchCriteria)?.DimensionNames; - if (criteria.EventNames?.Count > 1 && - AnalyticsFilterBuilder.HasItemDimensions(dimensionNames, criteria.DimensionFilters)) - { - throw new ArgumentException( - "An item-scoped read cannot be narrowed to more than one event name: its rows carry no event name to tell them apart.", - nameof(criteria)); - } + AnalyticsFilterBuilder.ValidateItemScope(criteria.EventNames, dimensionNames, criteria.DimensionFilters, nameof(criteria)); } protected virtual async Task GetOrCreateAsync( @@ -160,17 +144,7 @@ protected virtual async Task GetOrCreateAsync( protected virtual async Task ResolveSettingsAsync(string operation, string storeId) { - AnalyticsDataApiSettings settings; - - try - { - settings = await _settingsResolver.ResolveAsync(storeId); - } - catch (Exception ex) - { - LogFailure(operation, storeId, ex); - throw CreateReadException(operation, storeId); - } + var settings = await ResolveOrThrowAsync(operation, storeId); if (!settings.IsConfigured) { @@ -212,6 +186,19 @@ protected virtual AnalyticsException CreateReadException(string operation, strin return new AnalyticsException($"Google Analytics {operation} failed{DescribeStore(storeId)}."); } + private async Task ResolveOrThrowAsync(string operation, string storeId) + { + try + { + return await _settingsResolver.ResolveAsync(storeId); + } + catch (Exception ex) + { + LogFailure(operation, storeId, ex); + throw CreateReadException(operation, storeId); + } + } + // An absent store id is a legitimate unnarrowed read, not a missing value, so it must not read as "store ''". private static string DescribeStore(string storeId) { @@ -275,7 +262,7 @@ protected virtual async Task> GetEventNamesAsync( return rows.Events .Select(x => x.EventName) .Where(x => !string.IsNullOrEmpty(x)) - .Distinct(StringComparer.OrdinalIgnoreCase) + .DistinctIgnoreCase() .ToList(); } @@ -295,9 +282,7 @@ protected virtual async Task GetSummaryAsync( var result = AbstractTypeFactory.TryCreateInstance(); result.EventName = eventName; result.TotalCount = rows.TotalEventCount; - result.LastOccurredAt = result.TotalCount > 0 - ? rows.Events.Where(x => x.EventName.EqualsIgnoreCase(eventName)).Max(x => x.OccurredAt) - : null; + result.LastOccurredAt = result.TotalCount > 0 ? rows.Events.FirstOrDefault()?.OccurredAt : null; return result; } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs index 0b648b1..ec3f7f8 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/AnalyticsSettingsResolver.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -43,26 +42,21 @@ public virtual async Task ResolveAsync(string storeId) protected virtual async Task GetSettingAsync(ICollection storeSettings, SettingDescriptor descriptor) { - var hasStoreValue = storeSettings?.Any(x => - x.Name.EqualsIgnoreCase(descriptor.Name) && IsStoreOverride(x.Value)) == true; + var storeValue = storeSettings?.FirstOrDefault(x => x.Name.EqualsIgnoreCase(descriptor.Name))?.Value; - if (!hasStoreValue) + if (IsStoreOverride(storeValue)) { - return await _settingsManager.GetValueAsync(descriptor); - } + if (SettingValueConverter.TryConvert(storeValue, out var value)) + { + return value; + } - try - { - return storeSettings.GetValue(descriptor); - } - catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) - { // A misconfigured value is not a reason to fail the read. - _logger.LogWarning(ex, "Store setting {Setting} is not a valid {Type}; falling back to the global value", + _logger.LogWarning("Store setting {Setting} is not a valid {Type}; falling back to the global value", descriptor.Name, typeof(T).Name); - - return await _settingsManager.GetValueAsync(descriptor); } + + return await _settingsManager.GetValueAsync(descriptor); } // Not just null: the admin UI writes "" for a cleared ShortText and a value of spaces is the same gesture, diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs index a442e87..5a3bd77 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsDataSource.cs @@ -47,17 +47,12 @@ protected virtual async Task GetEventScopedRowsAsync protected virtual async Task GetItemScopedRowsAsync(AnalyticsDataQuery query) { // Refused at the service entry too; this is the backstop for a direct IAnalyticsDataSource caller. - if (query.EventNames?.Count > 1) - { - throw new ArgumentException( - "An item-scoped read cannot be narrowed to more than one event name: its rows carry no event name to tell them apart.", - nameof(query)); - } + AnalyticsFilterBuilder.ValidateItemScope(query.EventNames, query.DimensionNames, query.DimensionFilters, nameof(query)); var response = await _reportClient.RunReportAsync(BuildItemReportRequest(query), query.RequestTimeout); var result = MapResponse(response, query); - var eventName = query.EventNames?.Count == 1 ? query.EventNames[0] : null; + var eventName = query.EventNames?.FirstOrDefault(); if (!string.IsNullOrEmpty(eventName)) { foreach (var analyticsEvent in result.Events) @@ -99,7 +94,7 @@ protected virtual RunReportRequest CreateRequest(AnalyticsDataQuery query) { var request = new RunReportRequest { - Property = AnalyticsFilterBuilder.PropertyName(query.PropertyId), + Property = PropertyName.FormatProperty(query.PropertyId), Limit = query.Take > 0 ? query.Take : 1, Offset = query.Skip, }; @@ -193,10 +188,7 @@ protected virtual AnalyticsEventSearchResult MapResponse(RunReportResponse respo MapDimensionValue(analyticsEvent, headers[i], row.DimensionValues[i].Value, timeZone); } - analyticsEvent.Count = row.MetricValues.Count > 0 - && int.TryParse(row.MetricValues[0].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count) - ? count - : 0; + analyticsEvent.Count = AnalyticsFilterBuilder.ParseMetric(row.MetricValues.FirstOrDefault()?.Value); result.Events.Add(analyticsEvent); } @@ -207,9 +199,7 @@ protected virtual AnalyticsEventSearchResult MapResponse(RunReportResponse respo // A read that matched nothing may come back without a totals row at all, which is a total of 0. protected virtual int ParseMetricTotal(RunReportResponse response) { - var total = response.Totals.FirstOrDefault()?.MetricValues.FirstOrDefault()?.Value; - - return int.TryParse(total, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result) ? result : 0; + return AnalyticsFilterBuilder.ParseMetric(response.Totals.FirstOrDefault()?.MetricValues.FirstOrDefault()?.Value); } protected virtual void MapDimensionValue(AnalyticsEvent analyticsEvent, string dimensionName, string value, TimeZoneInfo timeZone) @@ -284,11 +274,9 @@ protected virtual TimeZoneInfo ResolvePropertyTimeZone(string timeZoneId) return null; } - var local = DateTime.SpecifyKind(result, DateTimeKind.Unspecified); - // The hour a spring-forward skips has no UTC instant, and converting one throws. - return timeZone.IsInvalidTime(local) - ? DateTime.SpecifyKind(local, DateTimeKind.Utc) - : TimeZoneInfo.ConvertTimeToUtc(local, timeZone); + return timeZone.IsInvalidTime(result) + ? DateTime.SpecifyKind(result, DateTimeKind.Utc) + : TimeZoneInfo.ConvertTimeToUtc(result, timeZone); } } diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs index 3e0d0ee..141f65e 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Data/Services/GoogleAnalyticsReportClient.cs @@ -10,7 +10,9 @@ namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; public class GoogleAnalyticsReportClient : IGoogleAnalyticsReportClient { - private static readonly string[] AnalyticsReadOnlyScopes = { "https://www.googleapis.com/auth/analytics.readonly" }; + internal const string AnalyticsReadOnlyScope = "https://www.googleapis.com/auth/analytics.readonly"; + + private static readonly string[] AnalyticsReadOnlyScopes = [AnalyticsReadOnlyScope]; // This client is a singleton and BetaAnalyticsDataClient is thread-safe. An explicitly supplied // GoogleCredential opts the builder out of the shared GAX channel pool, so building per call would leak @@ -31,7 +33,7 @@ public virtual Task ValidateCredentialAsync() public virtual async Task GetMetadataAsync(string propertyId, TimeSpan timeout) { var client = await GetClientAsync(); - return await client.GetMetadataAsync(new GetMetadataRequest { Name = $"properties/{propertyId}/metadata" }, CreateCallSettings(timeout)); + return await client.GetMetadataAsync(new GetMetadataRequest { MetadataName = MetadataName.FromProperty(propertyId) }, CreateCallSettings(timeout)); } public virtual async Task RunReportAsync(RunReportRequest request, TimeSpan timeout) diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs index 7f7c00f..5049d9e 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsDiagnosticsServiceTests.cs @@ -9,10 +9,8 @@ using Moq; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; -using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; -using VirtoCommerce.Platform.Core.Caching; using Xunit; using ErrorInfo = Google.Rpc.ErrorInfo; using GaMetadata = Google.Analytics.Data.V1Beta.Metadata; diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs index 9a217b2..88c16db 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsFilterBuilderTests.cs @@ -14,10 +14,15 @@ public class AnalyticsFilterBuilderTests { private const string ParamName = "criteria"; - [Fact] - public void PropertyName_PrefixesTheNumericId() + // A metric value Google leaves out or sends unparsable counts as zero rather than failing the read. + [Theory] + [InlineData("17", 17)] + [InlineData(null, 0)] + [InlineData("", 0)] + [InlineData("n/a", 0)] + public void ParseMetric_ReadsTheNumberOrZero(string value, int expected) { - Assert.Equal("properties/123456", AnalyticsFilterBuilder.PropertyName("123456")); + Assert.Equal(expected, AnalyticsFilterBuilder.ParseMetric(value)); } // Dropped rather than refused, a nameless filter widens the read to every organization. diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index 3deb2a2..2a3b737 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -6,7 +6,6 @@ using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; @@ -28,13 +27,7 @@ public class AnalyticsServiceTests private static readonly DateTime To = new(2026, 8, 25, 12, 0, 0, DateTimeKind.Utc); private readonly Mock _settingsResolverMock = new(); - private readonly Mock _reportClientMock = new(); - private readonly Mock _googleDataSourceMock; - - public AnalyticsServiceTests() - { - _googleDataSourceMock = new Mock(_reportClientMock.Object, NullLogger.Instance); - } + private readonly Mock _dataSourceMock = new(); [Theory] [InlineData(PropertyId, true)] @@ -78,7 +71,7 @@ public async Task SearchEventsAsync_DimensionFilterWithoutValues_ThrowsBeforeRes await Assert.ThrowsAsync(() => service.SearchEventsAsync(criteria)); _settingsResolverMock.Verify(x => x.ResolveAsync(It.IsAny()), Times.Never); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); } [Theory] @@ -144,7 +137,7 @@ public async Task SearchEventsAsync_WithoutStoreId_ReadsAgainstTheGlobalProperty _settingsResolverMock .Setup(x => x.ResolveAsync(storeId)) .ReturnsAsync(new AnalyticsDataApiSettings { PropertyId = PropertyId }); - _googleDataSourceMock + _dataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) .Callback((AnalyticsDataQuery query) => capturedQuery = query) .ReturnsAsync(CreateSearchResult(("search", To, 3))); @@ -176,7 +169,7 @@ public async Task SearchEventsAsync_WithoutStoreId_NothingConfiguredGlobally_Thr // Not "for store ''": an absent store id is a shape, not a missing value. Assert.DoesNotContain("''", failure.Message); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); } // "Not configured" is a configuration error, not an answer: a consumer handed an empty result would present @@ -188,7 +181,7 @@ public async Task SearchEventsAsync_NotConfigured_ThrowsWithoutQueryingSource() await Assert.ThrowsAsync(() => service.SearchEventsAsync(CreateSearchCriteria())); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); } // Only the Google call is cached. Configuration is re-read on every call, so the store configured a minute @@ -200,9 +193,7 @@ public async Task SearchEventsAsync_ConfiguredAfterAFailedRead_ReportsWithoutWai .SetupSequence(x => x.ResolveAsync(StoreId)) .ReturnsAsync(new AnalyticsDataApiSettings()) .ReturnsAsync(new AnalyticsDataApiSettings { PropertyId = PropertyId }); - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateService(); await Assert.ThrowsAsync(() => service.SearchEventsAsync(CreateSearchCriteria())); @@ -214,9 +205,7 @@ public async Task SearchEventsAsync_ConfiguredAfterAFailedRead_ReportsWithoutWai [Fact] public async Task SearchEventsAsync_SameCriteria_UsesCache() { - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateGoogleConfiguredService(); var first = await service.SearchEventsAsync(CreateSearchCriteria()); @@ -224,15 +213,13 @@ public async Task SearchEventsAsync_SameCriteria_UsesCache() Assert.Equal(1, first.TotalCount); Assert.Equal(1, second.TotalCount); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); } [Fact] public async Task SearchEventsAsync_DifferentCriteria_QueriesSourceAgain() { - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateGoogleConfiguredService(); await service.SearchEventsAsync(CreateSearchCriteria()); @@ -240,21 +227,19 @@ public async Task SearchEventsAsync_DifferentCriteria_QueriesSourceAgain() otherCriteria.SortBy = ModuleConstants.SortBy.Count; await service.SearchEventsAsync(otherCriteria); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } [Fact] public async Task SearchEventsAsync_DifferentDimensionFilterValues_UseDifferentCacheKeys() { - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateGoogleConfiguredService(); await service.SearchEventsAsync(CreateSearchCriteria(organizationId: "org1")); await service.SearchEventsAsync(CreateSearchCriteria(organizationId: "org2")); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } // The rows are OF a property: re-pointing the store must not keep serving the previous property's numbers. @@ -265,24 +250,20 @@ public async Task SearchEventsAsync_PropertyIdChanged_DoesNotServeThePreviousPro .SetupSequence(x => x.ResolveAsync(StoreId)) .ReturnsAsync(new AnalyticsDataApiSettings { PropertyId = "111111" }) .ReturnsAsync(new AnalyticsDataApiSettings { PropertyId = "222222" }); - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateService(); await service.SearchEventsAsync(CreateSearchCriteria()); await service.SearchEventsAsync(CreateSearchCriteria()); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.Is(q => q.PropertyId == "111111")), Times.Once); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.Is(q => q.PropertyId == "222222")), Times.Once); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.Is(q => q.PropertyId == "111111")), Times.Once); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.Is(q => q.PropertyId == "222222")), Times.Once); } [Fact] public async Task SearchEventsAsync_ReturnsClonedResult() { - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateGoogleConfiguredService(); var first = await service.SearchEventsAsync(CreateSearchCriteria()); @@ -296,7 +277,7 @@ public async Task SearchEventsAsync_ReturnsClonedResult() public async Task SearchEventsAsync_PropagatesCriteriaToQuery() { AnalyticsDataQuery capturedQuery = null; - _googleDataSourceMock + _dataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) .Callback((AnalyticsDataQuery query) => capturedQuery = query) .ReturnsAsync(CreateSearchResult(("search", To, 3))); @@ -320,7 +301,7 @@ public async Task SearchEventsAsync_PropagatesCriteriaToQuery() [Fact] public async Task SearchEventsAsync_SourceFails_ThrowsAndCachesTheFailure() { - _googleDataSourceMock + _dataSourceMock .SetupSequence(x => x.GetRowsAsync(It.IsAny())) .ThrowsAsync(new InvalidOperationException($"PermissionDenied on property {PropertyId}")) .ReturnsAsync(CreateSearchResult(("search", To, 3))); @@ -333,13 +314,13 @@ public async Task SearchEventsAsync_SourceFails_ThrowsAndCachesTheFailure() Assert.Contains(StoreId, failure.Message); Assert.DoesNotContain(PropertyId, failure.Message); Assert.Null(failure.InnerException); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); } [Fact] public async Task SearchEventsAsync_FailureCacheExpires_RecoversQuickly() { - _googleDataSourceMock + _dataSourceMock .SetupSequence(x => x.GetRowsAsync(It.IsAny())) .ThrowsAsync(new InvalidOperationException("GA responded 400")) .ReturnsAsync(CreateSearchResult(("search", To, 3))); @@ -350,7 +331,7 @@ public async Task SearchEventsAsync_FailureCacheExpires_RecoversQuickly() var recovered = await service.SearchEventsAsync(CreateSearchCriteria()); Assert.Equal(1, recovered.TotalCount); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } // Left at the platform's sliding default, the entry evicts on the sliding clock and CacheTtlMinutes means @@ -358,9 +339,7 @@ public async Task SearchEventsAsync_FailureCacheExpires_RecoversQuickly() [Fact] public async Task SearchEventsAsync_ShorterSlidingDefault_DoesNotEvictBeforeTheConfiguredTtl() { - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateService( new AnalyticsDataApiSettings { PropertyId = PropertyId, CacheTtlMinutes = 5 }, slidingExpiration: TimeSpan.FromMilliseconds(50)); @@ -369,7 +348,7 @@ public async Task SearchEventsAsync_ShorterSlidingDefault_DoesNotEvictBeforeTheC await Task.Delay(300, TestContext.Current.CancellationToken); await service.SearchEventsAsync(CreateSearchCriteria()); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); } [Fact] @@ -403,7 +382,7 @@ public async Task GetEventSummariesAsync_ReportsEachNamesTotalAndNewestBucket() public async Task GetEventSummariesAsync_OneReadFails_ThrowsRatherThanReportNever() { SetupSummaryRead(ModuleConstants.EventNames.Search, total: 3, newest: To); - _googleDataSourceMock + _dataSourceMock .Setup(x => x.GetRowsAsync(It.Is(q => q.EventNames.Contains(ModuleConstants.EventNames.Login)))) .ThrowsAsync(new InvalidOperationException("GA responded 429")); var service = CreateGoogleConfiguredService(); @@ -416,7 +395,7 @@ await Assert.ThrowsAsync(() => service.GetEventSummariesAsyn public async Task GetEventSummariesAsync_ReadsNarrowly_NeverTheWholeHourlySeries() { var queries = new ConcurrentQueue(); - _googleDataSourceMock + _dataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) .Callback(queries.Enqueue) .ReturnsAsync(new AnalyticsEventSearchResult()); @@ -447,7 +426,7 @@ public async Task GetEventSummariesAsync_NamedReads_StartTogether() { var started = 0; var allStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _googleDataSourceMock + _dataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) .Returns(async () => { @@ -473,7 +452,7 @@ await service.GetEventSummariesAsync(CreateSummaryCriteria( [Fact] public async Task GetEventSummariesAsync_NoNames_FindsTheNamesFirstThenReadsEach() { - _googleDataSourceMock + _dataSourceMock .Setup(x => x.GetRowsAsync(It.Is(q => q.SortBy == ModuleConstants.SortBy.Count))) .ReturnsAsync(CreateCountModeResult(("search", 3), ("login", 5))); SetupSummaryRead(ModuleConstants.EventNames.Search, total: 3, newest: To); @@ -495,13 +474,13 @@ public async Task GetEventSummariesAsync_NotConfigured_Throws() await Assert.ThrowsAsync(() => service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Login))); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Never); } [Fact] public async Task GetEventSummariesAsync_SourceFails_ThrowsAndCachesTheFailure() { - _googleDataSourceMock + _dataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) .ThrowsAsync(new InvalidOperationException("GA responded 400")); var service = CreateGoogleConfiguredService(); @@ -511,7 +490,7 @@ await Assert.ThrowsAsync(() => await Assert.ThrowsAsync(() => service.GetEventSummariesAsync(CreateSummaryCriteria(ModuleConstants.EventNames.Login))); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); } private AnalyticsService CreateService( @@ -538,8 +517,8 @@ private AnalyticsService CreateService( var logger = new Mock>().Object; return failureCacheTtl == null - ? new AnalyticsService(_settingsResolverMock.Object, platformMemoryCache, _googleDataSourceMock.Object, logger) - : new ShortFailureTtlAnalyticsService(failureCacheTtl.Value, _settingsResolverMock.Object, platformMemoryCache, _googleDataSourceMock.Object, logger); + ? new AnalyticsService(_settingsResolverMock.Object, platformMemoryCache, _dataSourceMock.Object, logger) + : new ShortFailureTtlAnalyticsService(failureCacheTtl.Value, _settingsResolverMock.Object, platformMemoryCache, _dataSourceMock.Object, logger); } private AnalyticsService CreateGoogleConfiguredService(TimeSpan? failureCacheTtl = null, bool cacheEnabled = true) @@ -551,7 +530,7 @@ private AnalyticsService CreateGoogleConfiguredService(TimeSpan? failureCacheTtl public async Task SearchEventsAsync_ReadCarriesTheStoresRequestTimeout() { AnalyticsDataQuery query = null; - _googleDataSourceMock + _dataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) .Callback(x => query = x) .ReturnsAsync(CreateSearchResult(("search", To, 3))); @@ -566,7 +545,7 @@ public async Task SearchEventsAsync_ReadCarriesTheStoresRequestTimeout() public async Task GetEventSummariesAsync_EveryReadCarriesTheStoresRequestTimeout() { var queries = new ConcurrentQueue(); - _googleDataSourceMock + _dataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) .Callback(queries.Enqueue) .ReturnsAsync(CreateSearchResult(("search", To, 3))); @@ -582,9 +561,7 @@ public async Task GetEventSummariesAsync_EveryReadCarriesTheStoresRequestTimeout [Fact] public async Task SearchEventsAsync_CriteriaDifferingOnlyInTimeOfDay_UsesCache() { - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateGoogleConfiguredService(); var morning = CreateSearchCriteria(); @@ -595,15 +572,13 @@ public async Task SearchEventsAsync_CriteriaDifferingOnlyInTimeOfDay_UsesCache() await service.SearchEventsAsync(morning); await service.SearchEventsAsync(evening); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Once); } [Fact] public async Task SearchEventsAsync_NormalizingDates_DoesNotMutateTheCallersCriteria() { - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateGoogleConfiguredService(); var criteria = CreateSearchCriteria(); @@ -618,9 +593,7 @@ public async Task SearchEventsAsync_NormalizingDates_DoesNotMutateTheCallersCrit [Fact] public async Task SearchEventsAsync_BypassCache_ReadsEveryTime() { - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateGoogleConfiguredService(); var criteria = CreateSearchCriteria(); @@ -629,13 +602,13 @@ public async Task SearchEventsAsync_BypassCache_ReadsEveryTime() await service.SearchEventsAsync(criteria); await service.SearchEventsAsync(criteria); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } [Fact] public async Task SearchEventsAsync_BypassCache_SourceFails_Throws() { - _googleDataSourceMock + _dataSourceMock .Setup(x => x.GetRowsAsync(It.IsAny())) .ThrowsAsync(new InvalidOperationException("GA responded 400")); var service = CreateGoogleConfiguredService(); @@ -650,29 +623,25 @@ public async Task SearchEventsAsync_BypassCache_SourceFails_Throws() [Fact] public async Task SearchEventsAsync_PlatformCachingDisabled_ReadsEveryTime() { - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateGoogleConfiguredService(cacheEnabled: false); await service.SearchEventsAsync(CreateSearchCriteria()); await service.SearchEventsAsync(CreateSearchCriteria()); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } [Fact] public async Task SearchEventsAsync_CacheTtlZero_ReadsEveryTime() { - _googleDataSourceMock - .Setup(x => x.GetRowsAsync(It.IsAny())) - .ReturnsAsync(CreateSearchResult(("search", To, 3))); + SetupRows(); var service = CreateService(new AnalyticsDataApiSettings { PropertyId = PropertyId, CacheTtlMinutes = 0 }); await service.SearchEventsAsync(CreateSearchCriteria()); await service.SearchEventsAsync(CreateSearchCriteria()); - _googleDataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); + _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } private static AnalyticsEventSearchCriteria CreateSearchCriteria(string organizationId = null) @@ -709,7 +678,7 @@ private static AnalyticsEventSummaryCriteria CreateSummaryCriteria(params string // A summary's per-name read: Google's total over every bucket, and only the newest bucket as a row. private void SetupSummaryRead(string eventName, int total, DateTime? newest) { - _googleDataSourceMock + _dataSourceMock .Setup(x => x.GetRowsAsync(It.Is(q => q.SortBy == ModuleConstants.SortBy.Date && q.EventNames.Contains(eventName)))) .ReturnsAsync(new AnalyticsEventSearchResult @@ -745,6 +714,13 @@ private static AnalyticsEventSearchResult CreateSearchResult(params (string Even }; } + private void SetupRows() + { + _dataSourceMock + .Setup(x => x.GetRowsAsync(It.IsAny())) + .ReturnsAsync(CreateSearchResult(("search", To, 3))); + } + private sealed class ShortFailureTtlAnalyticsService : AnalyticsService { private readonly TimeSpan _failureCacheTtl; @@ -753,9 +729,9 @@ public ShortFailureTtlAnalyticsService( TimeSpan failureCacheTtl, IAnalyticsSettingsResolver settingsResolver, IPlatformMemoryCache platformMemoryCache, - GoogleAnalyticsDataSource googleAnalyticsDataSource, + IAnalyticsDataSource dataSource, ILogger logger) - : base(settingsResolver, platformMemoryCache, googleAnalyticsDataSource, logger) + : base(settingsResolver, platformMemoryCache, dataSource, logger) { _failureCacheTtl = failureCacheTtl; } diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsControllerTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsControllerTests.cs index dd556cf..b1eb652 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsControllerTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/GoogleAnalyticsControllerTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Moq; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models; using VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Services; @@ -50,15 +51,14 @@ public async Task RunDiagnostics_PassesTheStoreAndRequestThrough_AndReturnsTheRe { Checks = [new AnalyticsDiagnosticsCheck { Stage = ModuleConstants.DiagnosticsStages.Configuration }], }; - var diagnosticsService = new FakeDiagnosticsService(report); - var controller = new GoogleAnalyticsController(new FakeSettingsManager(), null, diagnosticsService); var request = new AnalyticsDiagnosticsRequest { EventNames = [ModuleConstants.EventNames.Search] }; + var diagnosticsService = new Mock(); + diagnosticsService.Setup(x => x.RunAsync("B2B-store", request)).ReturnsAsync(report); + var controller = new GoogleAnalyticsController(Mock.Of(), null, diagnosticsService.Object); var response = await controller.RunDiagnostics("B2B-store", request); Assert.Same(report, Assert.IsType(response.Result).Value); - Assert.Equal("B2B-store", diagnosticsService.StoreId); - Assert.Same(request, diagnosticsService.Request); } private static AuthorizeAttribute[] GetAuthorizeAttributes(string actionName) @@ -70,34 +70,4 @@ private static AuthorizeAttribute[] GetAuthorizeAttributes(string actionName) .GetCustomAttributes() .ToArray(); } - - private sealed class FakeDiagnosticsService : IAnalyticsDiagnosticsService - { - private readonly AnalyticsDiagnosticsResult _report; - - public FakeDiagnosticsService(AnalyticsDiagnosticsResult report) - { - _report = report; - } - - public string StoreId { get; private set; } - - public AnalyticsDiagnosticsRequest Request { get; private set; } - - public Task RunAsync(string storeId, AnalyticsDiagnosticsRequest request) - { - StoreId = storeId; - Request = request; - - return Task.FromResult(_report); - } - } - - private sealed class FakeSettingsManager : IGoogleAnalyticsSettingsManager - { - public Task GetAsync(string storeId) - { - return Task.FromResult(new GoogleAnalyticsSettings()); - } - } } From 60df4729ba688ba9489541f3c4d28991141b3942 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Tue, 13 Oct 2026 12:39:45 -0600 Subject: [PATCH 35/36] Let the report cache lifetime be 0 0 turns the cache off, as the docs say, but the setting was a PositiveInteger, which the platform refuses to save as 0. It is an Integer now; the read already treats 0 and below as no cache. Co-Authored-By: Claude Opus 5.5 --- .../ModuleConstants.cs | 4 +++- .../AnalyticsServiceTests.cs | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index 253107c..82c95c6 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -151,7 +151,9 @@ public static class DataApi { Name = "GoogleAnalytics4.DataApi.CacheTtlMinutes", GroupName = "Google Analytics 4", - ValueType = SettingValueType.PositiveInteger, + // Integer, not PositiveInteger: 0 turns the cache off, and the platform refuses to save 0 for a + // PositiveInteger setting. + ValueType = SettingValueType.Integer, DefaultValue = DefaultCacheTtlMinutes }; diff --git a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs index 2a3b737..32c2446 100644 --- a/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs +++ b/tests/VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests/AnalyticsServiceTests.cs @@ -15,6 +15,7 @@ using VirtoCommerce.GoogleEcommerceAnalyticsModule.Data.Services; using VirtoCommerce.Platform.Caching; using VirtoCommerce.Platform.Core.Caching; +using VirtoCommerce.Platform.Core.Settings; using Xunit; namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Tests; @@ -644,6 +645,13 @@ public async Task SearchEventsAsync_CacheTtlZero_ReadsEveryTime() _dataSourceMock.Verify(x => x.GetRowsAsync(It.IsAny()), Times.Exactly(2)); } + // The read above is only reachable if 0 can be saved: the platform refuses 0 for a PositiveInteger setting. + [Fact] + public void CacheTtlMinutesSetting_AcceptsZero() + { + Assert.Equal(SettingValueType.Integer, ModuleConstants.Settings.DataApi.CacheTtlMinutes.ValueType); + } + private static AnalyticsEventSearchCriteria CreateSearchCriteria(string organizationId = null) { var criteria = new AnalyticsEventSearchCriteria From 52673025f1f17c458034c65c378dca5c5318bda8 Mon Sep 17 00:00:00 2001 From: yuskithedeveloper Date: Thu, 15 Oct 2026 13:51:26 -0600 Subject: [PATCH 36/36] Drop the organization_name user dimension An organization name can be a person's name (a sole trader's), and GA forbids personal data in custom dimensions. The storefront stops sending it (VirtoCommerce/vc-frontend#2458), and nothing reads it. Co-Authored-By: Claude Opus 5.5 --- .../ModuleConstants.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs index 82c95c6..82d8222 100644 --- a/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs +++ b/src/VirtoCommerce.GoogleEcommerceAnalyticsModule.Core/ModuleConstants.cs @@ -32,11 +32,10 @@ public static class UserDimensions public const string ContactId = "contact_id"; public const string OrganizationId = "organization_id"; - public const string OrganizationName = "organization_name"; public const string IsSalesRep = "is_sales_rep"; public const string SessionKind = "session_kind"; - public static string[] AllNames { get; } = [ContactId, OrganizationId, OrganizationName, IsSalesRep, SessionKind]; + public static string[] AllNames { get; } = [ContactId, OrganizationId, IsSalesRep, SessionKind]; } public static class Dimensions