@stream-vessel/ng-ga4
v0.2.2
Published
Angular library for Google Analytics 4 via Measurement Protocol, with support for Angular web apps and Chrome extensions.
Maintainers
Readme
@stream-vessel/ng-ga4
Google Analytics 4 for Angular over the Measurement Protocol. It loads no remote script, so it works under a strict CSP and in Chrome extensions (Manifest V3), following Google's Chrome Extensions GA4 guide.
Used in production in the Stream Vessel Chrome extension.
Features
- No
gtag.jsand no external scripts - Automatic page views from the Angular Router
- Persistent client ID and 30-minute sessions, kept in step across tabs
- Real engagement time: visible and focused time since the last hit
- Chrome extension support (
chrome.storage), including MV3 service-worker teardown - Shares the
_gaclient ID withgtag.jswhen both run on a site - Consent Mode (
ad_user_data,ad_personalization) and a storage-consent switch - Device and browser detection on every browser, not only Chromium
- Debug mode that shows events in DebugView and still records them
- Safe under SSR; works with NgModule and standalone apps
What you give up
This library sends data to GA4 straight from your code, without Google's tracking script (gtag.js). That is why it works where the script can't, but GA4 learns less about each visit, so some reports stay empty:
- Where visitors came from: every visit shows as "direct", with no search, link or campaign data.
- New users: always 0.
- Engaged sessions, engagement rate and bounce rate: always 0, so you can't use them.
- Age, gender, interests, and Google Ads remarketing: no data.
- City and region: no data. The country still shows, guessed from the browser's time zone.
These work as usual: events, page views, active users and average engagement time.
We checked all of this on a real GA4 property; LIMITATIONS.md has the details.
If your app can load Google's script, use gtag.js instead. This library is for when it can't.
Install
npm install @stream-vessel/ng-ga4You need a GA4 property and a Measurement Protocol API secret (Admin → Data Streams → your stream → Measurement Protocol API secrets).
NgModule apps
import { NgGa4Module } from '@stream-vessel/ng-ga4';
@NgModule({
imports: [
NgGa4Module.forRoot({
measurementId: 'G-XXXXXXXXX',
apiSecret: 'your-api-secret',
enabled: true,
isExtension: false,
}),
],
})
export class AppModule {}Standalone apps
import { provideNgGa4 } from '@stream-vessel/ng-ga4';
bootstrapApplication(AppComponent, {
providers: [
provideNgGa4({
measurementId: 'G-XXXXXXXXX',
apiSecret: 'your-api-secret',
enabled: true,
isExtension: false,
}),
],
});Configuration
Required
measurementId(string): GA4 Measurement ID, e.g.G-XXXXXXXXX.apiSecret(string): Measurement Protocol API secret.enabled(boolean): whether collection starts on.falsecreates no client ID and writes nothing untilsetEnabled(true). See Consent.isExtension(boolean):truein a Chrome extension, so state goes tochrome.storageinstead oflocalStorage.
Optional
siteUrl(string): base ofpage_location, e.g.https://example.com. Needed in extensions, where the page address ischrome-extension://…. Without it,page_locationis the page's full address.appVersion(string): sent asapp_versionon every event.transport('beacon' | 'xhr', default'beacon'):'beacon'triessendBeacon, thenfetchwithkeepalive, then XHR, so hits survive the page closing.'xhr'sends throughHttpClientand your interceptors, but a request still in flight when the page closes is lost.debug(boolean, defaultfalse): see Debug mode.clientIdSource('auto' | 'cookie' | 'storage', default'auto'): where the web client ID comes from. See Client ID and gtag.js. An unknown value logs a warning and falls back to'auto', which reads_ga. Ignored in extensions.writeGaCookie(booleanorNgGa4CookieOptions, defaultfalse): write a_gacookie when there is no valid one (web only). See Client ID and gtag.js.sendEngagementOnHide(boolean, defaulttrue): send the engagement time left over when the page is hidden or closed. See Engagement time.engagementEventName(string, default'page_engagement'): name of that event. An invalid or reserved name (such asuser_engagement) logs a warning and falls back to the default.consent(NgGa4Consent, default: storage granted): initial consent state. See Consent.
Usage
Page views are sent on every Router NavigationEnd; you don't need to write anything for them.
import { NgGa4Service } from '@stream-vessel/ng-ga4';
export class MyComponent {
constructor(private analytics: NgGa4Service) {}
onLogin() {
this.analytics.trackEvent('login', { method: 'google' });
}
onCustomView() {
this.analytics.trackPageView('/custom-page', 'Custom Page Title');
}
}API
| NgGa4Service method | Description |
|---|---|
| trackEvent(name, params?) | Send a custom event. |
| trackPageView(path, title?) | Send a page view. Called for you on navigation. |
| setConsent(consent) | Update consent. Keys you leave out stay as they are. |
| setEnabled(enabled) | Turn collection on or off. The first true runs the initialisation that enabled: false skipped. |
| flushStorage() | Resolves once pending chrome.storage writes have landed. Resolves at once on the web. |
| init() | Starts the library. Runs for you at app start. |
Calls made while collection is off are dropped. Calls made after init() has started but before it finishes are queued and sent once it does; calls made before it starts (for example from an earlier APP_INITIALIZER) are dropped.
Also exported: seedNgGa4ClientId() (see Chrome extensions), NgGa4Config, NgGa4CookieOptions, NgGa4Consent, NgGa4ConsentState and NG_GA4_CONFIG.
Engagement time
Every hit carries engagement_time_msec: the time since the previous hit during which the page was visible and focused, the same interval gtag.js measures.
- Switching tabs, windows or apps, or minimising the browser, stops the clock. Coming back starts it again.
- Focus inside a child frame (an
iframe, an embedded viewer) doesn't count. - If your app itself runs inside another page's iframe, it only counts while that iframe has focus, so a reader who never clicks into it reports no engagement.
- Losing focus sends nothing; the time rides on the next hit.
- When the page is hidden or closed, the time left over goes out as one
page_engagementevent (seesendEngagementOnHide), always over the unload-safe transport. That event waits until at least 1 second has built up; anything shorter rides on the next hit. Itspage_locationis the last path you reported in a page view; withsiteUrlset and no page view yet, it has none, so the raw page address (and anything in its query) never goes out. Without this event, a visit with a single page view would report almost no engagement.
This feeds GA4's average engagement time. It does not make sessions count as engaged, so engaged sessions, engagement rate and bounce rate stay unusable (#43). Expect the numbers to come out close to gtag.js's, possibly a little lower, since this library also stops the clock when the window loses focus.
Client ID and gtag.js
On the web the client ID comes from clientIdSource:
'auto'(default): use the_gacookie when it is present and well formed, else this library's own ID inlocalStorage. A site running bothgtag.jsand this library counts each person once, across subdomains too. The adopted ID is never copied into storage, so deleting_gareturns to the library's own ID.'cookie':_gais the only source. One is created if missing (this implieswriteGaCookie), reusing an ID already stored ingtag.js's format. An older UUID-style ID is replaced, so those users are counted as new once.'storage':localStorageonly._gais never read or written.
Reading an existing cookie is itself a consent-relevant act. Use 'storage' if this library must never read _ga.
A _ga value is accepted only in gtag.js's own format, with an ID of at most 64 characters (letters, digits, ., _, -). Anything else is ignored. This keeps out malformed and oversized values; it can't tell a well-formed cookie planted from another subdomain from a real one.
writeGaCookie writes _ga when there is none or it fails the check above, using the library's own ID, on the registrable domain, with SameSite=Lax, Secure on HTTPS and gtag.js's two-year expiry. An ID adopted from a cookie is never written back. Pass NgGa4CookieOptions to change the defaults:
domain: e.g.'.example.com'. Without it, the library finds the registrable domain by setting a short-lived probe cookie (_ng_ga4_probe_<random>) at each candidate and keeping the first the browser accepts. If the browser refuses every candidate, no_gais written at all; a single-label host such aslocalhostgets a host-only cookie. Setdomainto skip the probe, for instance if a consent platform needs a fixed list of cookie names.flags: replaces the default attributes, e.g.'SameSite=None; Secure'for a cross-site iframe.maxAgeSeconds: lifetime, two years by default.
Upgrading a site that also runs gtag.js moves each returning user onto the gtag.js identity, once. That is a one-off jump in user counts; plan when you ship it.
Consent
With no consent option and no setConsent() call, nothing changes: no consent field is sent, and GA4 applies your property's defaults.
For a consent banner, start disabled and enable when the user accepts:
// In your providers: no client ID is created and nothing is stored yet.
provideNgGa4({ measurementId: 'G-XXXXXXXXX', apiSecret: 'your-api-secret', enabled: false, isExtension: false })
// In your consent banner component:
constructor(private ga4: NgGa4Service) {}
onBannerAccepted(): void {
this.ga4.setConsent({ analyticsStorage: 'granted', adUserData: 'granted', adPersonalization: 'denied' });
this.ga4.setEnabled(true);
}Events fired while disabled are dropped, not queued, so the first page view before consent is lost. That is on purpose.
| Signal | Sent to GA4 | Effect |
|---|---|---|
| adUserData | as consent.ad_user_data | GA4 acts on it |
| adPersonalization | as consent.ad_personalization | GA4 acts on it |
| analyticsStorage | no | Whether this library stores identifiers |
Denying analyticsStorage stops every write to localStorage, chrome.storage and _ga, and deletes what is already stored, also on a start-up that begins denied. Collection continues with an ID held in memory, so each page load is a new user and session. Under 'storage' the cookie is left alone; where gtag.js also runs, it writes _ga again on its next hit.
Things to know:
- This library doesn't store consent, and each tab or extension context has its own. Call
setConsent()on every start-up from your own record of the user's choice, in every context. A tab that is still granted keeps writing session data to the shared store after another tab denies. setEnabled(false)stops sending but deletes nothing. To remove stored identifiers, denyanalyticsStorage.- Granting again restores the client ID and session at once, but
_gaonly on the next page load. - An invalid consent value counts as
denied, with a warning.
Chrome extensions
- Add the
storagepermission tomanifest.json:"permissions": ["storage"]. The default extension CSP is fine, since no remote script is loaded. - Set
isExtension: trueand asiteUrl:
NgGa4Module.forRoot({
measurementId: 'G-XXXXXXXXX',
apiSecret: 'your-api-secret',
enabled: true,
isExtension: true,
siteUrl: 'https://your-extension-domain.com',
});| Data | Web | Extension |
|---|---|---|
| Client ID | _ga if present (default), else localStorage | chrome.storage.local |
| Session number | localStorage | chrome.storage.local |
| Session ID and last activity | localStorage | chrome.storage.session |
Service-worker teardown
Chrome stops an MV3 service worker once it goes idle, and a chrome.storage write still pending at that moment is lost. If the lost write was a new client ID, the next start creates another one and the user is counted twice.
Create the client ID at install time. Register this at the top level of the service worker, outside Angular:
import { seedNgGa4ClientId } from '@stream-vessel/ng-ga4';
chrome.runtime.onInstalled.addListener(() => seedNgGa4ClientId());Run it on every onInstalled, not only reason === 'install': users of an already published extension only ever get 'update'. The call is idempotent and never throws. It doesn't help a user who clears extension storage, since onInstalled doesn't fire again.
Wait for pending writes before an event handler returns, after tracking calls and after setConsent():
chrome.action.onClicked.addListener(async () => {
ga.trackEvent('action_clicked');
await ga.flushStorage();
});flushStorage() covers storage, not event delivery (#26). Neither step survives a browser crash or an extension reload.
Sessions across tabs
Every hit first reads the stored session and adopts a newer one, so tabs agree on the current session. On the web that read comes from localStorage. Extensions can't read chrome.storage synchronously, so they follow it through chrome.storage.onChanged.
Debug mode
debug: true tags every event with debug_mode, so it shows in DebugView. Events are still recorded and go out over your configured transport. Each payload is also posted to the validation endpoint, and any problems it reports are logged with console.warn.
provideNgGa4({ ...config, debug: !environment.production })Other notes
- Server-side rendering: on the server the library does nothing: no storage and no hits. Calls made there are dropped, not replayed, since the browser sends its own page view once the app starts.
- Device detection: the Measurement Protocol ignores the
User-Agentheader, so the library sends adeviceobject built from User-Agent Client Hints where available (Chromium), filled in from a built-in user-agent parser elsewhere. - API secret: anyone can read it from the built bundle; that is inherent to the Measurement Protocol in a browser. It lets someone send fake events, not read your data. If you see abuse, delete the secret in GA4, create a new one and redeploy.
Peer dependencies
@angular/core,@angular/common,@angular/router:^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0rxjs:^7.0.0
CI tests Angular 18 only. Reports of breakage on other majors are welcome.
Local development
pnpm install
pnpm run build # builds into dist/ng-ga4
pnpm test # Karma/Jasmine, headless Chrome
pnpm run lintTo try an unreleased build in another app, run pnpm run build here and pnpm add file:../ng-ga4/dist/ng-ga4 there. Switch back to the registry version before committing that app.
License
Apache-2.0. See LICENSE.
