Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
fc0d84f
Implementation (draft)
yuskithedeveloper Aug 25, 2026
0b6152e
Implementation (draft)
yuskithedeveloper Aug 25, 2026
27504b6
Diagnostics
yuskithedeveloper Aug 27, 2026
954f850
Refactoring (SonarQube issues)
yuskithedeveloper Aug 27, 2026
ffb3a9a
SampleAnalyticsDataSource removed
yuskithedeveloper Aug 27, 2026
a1930f2
Obsolete settings removed
yuskithedeveloper Aug 27, 2026
9259080
CI trigger
yuskithedeveloper Aug 27, 2026
47ba6ff
CI trigger
yuskithedeveloper Aug 27, 2026
e1b654d
Localizations
yuskithedeveloper Aug 27, 2026
511b064
Refactoring (/vc-module-review)
yuskithedeveloper Aug 28, 2026
cc81ac5
Refactoring (/simplify)
yuskithedeveloper Aug 28, 2026
e42ff86
Refactoring
yuskithedeveloper Aug 28, 2026
c65e83c
Refactoring
yuskithedeveloper Aug 28, 2026
d4e9fe5
Refactoring (optimization)
yuskithedeveloper Aug 28, 2026
ec9334d
Refactoring
yuskithedeveloper Aug 28, 2026
dbbb180
Refactoring (optimization)
yuskithedeveloper Aug 28, 2026
8c65a46
Refactoring (optimization)
yuskithedeveloper Aug 28, 2026
2879ba4
Refactoring
yuskithedeveloper Aug 31, 2026
1ad9bd3
README.md
yuskithedeveloper Aug 31, 2026
5a275e7
Refactoring (/vc-module-review)
yuskithedeveloper Sep 2, 2026
217d57e
Refactoring (after alexeyshibanov review)
yuskithedeveloper Sep 9, 2026
2c217b2
Refactoring (/vc-module-review)
yuskithedeveloper Sep 9, 2026
2712d8b
Refactoring (after alexeyshibanov review, round #2)
yuskithedeveloper Sep 16, 2026
da20e15
Merge branch 'dev' into feat/VCST-5337-ga-event-search
yuskithedeveloper Sep 16, 2026
632e065
Empty StoreId handling
yuskithedeveloper Sep 16, 2026
a4a2411
Refactoring (/vc-module-review)
yuskithedeveloper Sep 16, 2026
da8a37a
Docs, comments
yuskithedeveloper Sep 16, 2026
375f9b7
index.md
yuskithedeveloper Sep 17, 2026
2bc5e8c
Comment
yuskithedeveloper Sep 17, 2026
f88af53
Additional tests
yuskithedeveloper Sep 17, 2026
bd937ca
Comment
yuskithedeveloper Sep 17, 2026
875a1ec
Merge branch 'dev' into feat/VCST-5337-ga-event-search
yuskithedeveloper Sep 29, 2026
0f15eed
Merge branch 'dev' into feat/VCST-5337-ga-event-search
yuskithedeveloper Oct 6, 2026
6c7c5b6
Merge branch 'dev' into feat/VCST-5337-ga-event-search
yuskithedeveloper Oct 9, 2026
e305c55
Add a Data API request timeout setting (default 45 s)
yuskithedeveloper Oct 9, 2026
fb3c7aa
Return the event total beside the row count
yuskithedeveloper Oct 9, 2026
02e2d47
Read a named summary in one round
yuskithedeveloper Oct 9, 2026
de1a54d
Simplify the analytics read code
yuskithedeveloper Oct 13, 2026
60df472
Let the report cache lifetime be 0
yuskithedeveloper Oct 13, 2026
0d16a43
Merge branch 'dev' into feat/VCST-5337-ga-event-search
yuskithedeveloper Oct 15, 2026
5267302
Drop the organization_name user dimension
yuskithedeveloper Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<!-- These properties will be shared for all projects -->
<PropertyGroup>
<VersionPrefix>4.1003.0</VersionPrefix>
<VersionPrefix>4.1004.0</VersionPrefix>
<VersionSuffix></VersionSuffix>
<VersionSuffix Condition=" '$(VersionSuffix)' != '' AND '$(BuildNumber)' != '' ">$(VersionSuffix)-$(BuildNumber)</VersionSuffix>
</PropertyGroup>
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -107,6 +111,28 @@ 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.

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, 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)
* [Google Tag Manager](https://developers.google.com/tag-platform/tag-manager)
Expand Down
125 changes: 125 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,95 @@ 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. 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. Failed reads are cached too, briefly - see [When a read fails](#when-a-read-fails).

## 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 <your-gcp-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.

### 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 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`.

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.

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
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".

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


## Rest API

### Get Google Analytics Settings
Expand All @@ -91,5 +180,41 @@ 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.

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:

```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.
14 changes: 14 additions & 0 deletions module.ignore
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System;

namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Exceptions;

// 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)
: base(message)
{
}

public AnalyticsException(string message, Exception innerException)
: base(message, innerException)
{
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Collections.Generic;

namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models;

public class AnalyticsDiagnosticsReportShape
{
public string Name { get; set; }

public IList<string> DimensionNames { get; set; } = [];

public string MetricName { get; set; }

public IList<string> EventNames { get; set; } = [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Collections.Generic;

namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models;

public class AnalyticsDiagnosticsRequest
{
public IList<string> UserDimensionNames { get; set; } = [];

public IList<string> EventNames { get; set; } = [];

public IList<AnalyticsDiagnosticsReportShape> Reports { get; set; } = [];

public bool IncludeLiveData { get; set; } = true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Collections.Generic;

namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models;

public class AnalyticsDiagnosticsResult
{
public IList<AnalyticsDiagnosticsCheck> Checks { get; set; } = [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using VirtoCommerce.Platform.Core.Common;

namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models;

public class AnalyticsDimensionFilter : ValueObject
{
public string DimensionName { get; set; }

public IList<string> Values { get; set; } = [];
}
Original file line number Diff line number Diff line change
@@ -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<string, string> Dimensions { get; set; } = new Dictionary<string, string>();

public virtual object Clone()
{
var result = (AnalyticsEvent)MemberwiseClone();
result.Dimensions = Dimensions != null ? new Dictionary<string, string>(Dimensions) : null;
return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using VirtoCommerce.Platform.Core.Common;

namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models;

public abstract class AnalyticsEventCriteriaBase : SearchCriteriaBase
{
public string StoreId { get; set; }

public IList<string> EventNames { get; set; } = [];

public IList<AnalyticsDimensionFilter> DimensionFilters { get; set; } = [];

public DateTime? From { get; set; }

public DateTime? To { get; set; }

// For a diagnostics probe: a feature read must not set it, since the cache is what protects the quota.
public bool BypassCache { get; set; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. This is a public settable flag that switches off what the module's own docs call a quota requirement rather than tuning, and the only thing keeping a feature read from setting it is this comment. Nothing in the repo sets it today.

The shape to watch is a consumer binding an AnalyticsEventSearchCriteria from caller-supplied input — the flag then rides along with it. Narrower access, or a separate criteria type for the probe, would make the rule structural rather than advisory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred, not disagreed — a parameter would make the rule structural instead of a comment. Leaving it while this surface is still moving; noted for the pass that changes these signatures.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Collections.Generic;

namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models;

public class AnalyticsEventSearchCriteria : AnalyticsEventCriteriaBase
{
public IList<string> DimensionNames { get; set; } = [];

public string SortBy { get; set; } = ModuleConstants.SortBy.Date;
}
Original file line number Diff line number Diff line change
@@ -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<AnalyticsEvent> Events { get; set; } = [];

public virtual object Clone()
{
var result = (AnalyticsEventSearchResult)MemberwiseClone();
result.Events = Events?.Select(x => x.CloneTyped()).ToList();
return result;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace VirtoCommerce.GoogleEcommerceAnalyticsModule.Core.Models;

public class AnalyticsEventSummaryCriteria : AnalyticsEventCriteriaBase
{
}
Loading
Loading