@technoapple/ga4
Version:
TypeScript Node.js library to support GA4 analytics.
549 lines (423 loc) • 24.7 kB
Markdown
# Project Requirements — @technoapple/ga4 v2.0
> **Version:** 2.0
> **Last Updated:** 2026-02-24
> **Author:** keke78ui9
> **Status:** Draft
> **Reference:** [googleanalytics/autotrack](https://github.com/googleanalytics/autotrack) — tracking concepts adapted for GA4
---
## 1. Overview
**Project Name:** @technoapple/ga4
**Description:** A TypeScript library that provides functions to support sending GA4 events, interacting with `window.dataLayer`, and **automatic tracking plugins** — providing enhanced tracking capabilities beyond GA4's built-in enhanced measurement.
**Goals:**
- Provide automatic tracking plugins for GA4 via `gtag()` / `dataLayer`
- Maintain the existing `ga4.init()`, `ga4.send()`, `ga4.gtag`, and `dataLayerHelper.get()` APIs
- Provide each plugin as an opt-in module (tree-shakeable) so consumers only pay for what they use
- Written in TypeScript with full type safety, following the existing codebase patterns
- Zero third-party runtime dependencies (browser APIs only)
**Out of Scope:**
- Server-side tracking / Measurement Protocol
- Google Tag Manager container management
---
## 2. Background & Motivation
GA4's built-in enhanced measurement covers some basic automatic tracking (page views, scroll to 90%, outbound clicks, site search, video engagement, file downloads). However, many advanced tracking scenarios are not covered:
- **Declarative event tracking** via HTML attributes (no JS needed)
- **Granular scroll depth** tracking is limited — GA4 only fires a single event at 90%
- **Page visibility** duration (time in foreground vs. background tab)
- **SPA URL changes** require manual setup in many frameworks
- **Element impression** tracking (ads, CTAs entering the viewport)
- **Outbound form** submit tracking
- **URL normalization** to prevent fragmented reporting
- **Media query / breakpoint** change tracking
This library provides these capabilities as TypeScript plugins that integrate with GA4 via `gtag('event', ...)`.
> **Note:** GA4 enhanced measurement already tracks scroll events (at 90% depth). This library does **not** duplicate that — instead it focuses on capabilities GA4 does not provide out of the box.
---
## 3. Functional Requirements — Existing Features (Already Implemented)
| ID | Requirement | Priority | Status |
|--------|-------------------------------------|----------|-----------|
| FR-001 | GA4 initialization via `ga4.init()` | High | ✅ Done |
| FR-002 | Send events via `ga4.send()` | High | ✅ Done |
| FR-003 | Direct `gtag()` access | High | ✅ Done |
| FR-004 | Read values from `dataLayer` | Medium | ✅ Done |
---
## 4. Functional Requirements — New Plugins
### 4.1 Plugin Summary
| # | Plugin | Priority | Rationale |
|---|--------------------------|----------|-----------|
| 1 | `eventTracker` | High | Declarative event tracking via HTML `data-*` attributes. No JS needed for page authors. |
| 2 | `outboundLinkTracker` | High | Click delegation on `<a>` elements, compare hostnames. Uses `navigator.sendBeacon` for reliability. |
| 3 | `outboundFormTracker` | Medium | Submit delegation on `<form>` elements with external `action` URLs. |
| 4 | `pageVisibilityTracker` | High | `document.visibilitychange` API. Track visible/hidden time. Handle session timeout. |
| 5 | `urlChangeTracker` | High | `popstate` + monkey-patch `history.pushState`/`replaceState` for SPA `page_view` tracking. |
| 6 | `impressionTracker` | Medium | `IntersectionObserver` + `MutationObserver`. Track element visibility in viewport. |
| 7 | `cleanUrlTracker` | Medium | Normalize URLs before sending `page_view` (strip query params, trailing slashes, force lowercase). |
| 8 | `mediaQueryTracker` | Low | `window.matchMedia` API. Track responsive breakpoint changes. |
### 4.2 Detailed Plugin Requirements
---
#### FR-100: `eventTracker` — Declarative Event Tracking via HTML Attributes
**Description:**
Allow page authors to track user interactions by adding `data-ga4-*` attributes to HTML elements, without writing JavaScript. The plugin listens for DOM events (click, submit, etc.) on elements matching a configurable selector and sends GA4 events based on attribute values.
**Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `events` | `string[]` | `['click']` | DOM event types to listen for |
| `attributePrefix` | `string` | `'data-ga4-'` | Prefix for data attributes |
| `hitFilter` | `(params, element, event) => params \| null` | `undefined` | Filter/modify params before sending |
**HTML Example:**
```html
<button
data-ga4-on="click"
data-ga4-event-name="video_play"
data-ga4-video-title="My Video"
data-ga4-video-id="abc123">
Play video
</button>
```
**Sent as:**
```js
gtag('event', 'video_play', { video_title: 'My Video', video_id: 'abc123' });
```
**Acceptance Criteria:**
- [ ] Reads event name from `data-ga4-event-name` attribute
- [ ] Reads all `data-ga4-*` attributes as event parameters (kebab-case → snake_case)
- [ ] Supports configurable event types (`click`, `submit`, `change`, etc.)
- [ ] Uses event delegation on `document` for performance
- [ ] Provides `remove()` method to clean up listeners
- [ ] Does not throw errors when attributes are missing
**Implementation Notes:**
- Use event delegation (single listener on `document`) for performance
- Convert `data-ga4-video-title` → `video_title` parameter name
- Implement lightweight internal `delegate()` utility (zero dependencies)
---
#### FR-101: `outboundLinkTracker` — Automatic Outbound Link Click Tracking
**Description:**
Automatically detect when a user clicks a link pointing to an external domain and send a GA4 event.
**Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `events` | `string[]` | `['click']` | DOM events to listen for (e.g. add `'auxclick'`, `'contextmenu'`) |
| `linkSelector` | `string` | `'a, area'` | CSS selector for link elements |
| `shouldTrackOutboundLink` | `(link: HTMLAnchorElement, parseUrl: Function) => boolean` | hostname !== location.hostname | Customize outbound detection |
| `eventName` | `string` | `'outbound_link_click'` | GA4 event name |
| `attributePrefix` | `string` | `'data-ga4-'` | Prefix for declarative attribute overrides |
| `hitFilter` | `Function` | `undefined` | Filter/modify params before sending |
**Default event parameters sent:**
| Parameter | Value |
|-----------|-------|
| `event_name` | `'outbound_link_click'` |
| `link_url` | Full href of the clicked link |
| `link_domain` | Hostname of the outbound link |
| `outbound` | `true` |
**Acceptance Criteria:**
- [ ] Detects clicks on `<a>` and `<area>` elements pointing to external domains
- [ ] Uses `navigator.sendBeacon` transport for reliability (page may unload)
- [ ] Supports right-click and middle-click tracking via `events` option
- [ ] Provides `shouldTrackOutboundLink` callback for custom domain logic
- [ ] Provides `remove()` method to clean up all event listeners
- [ ] Handles links with `xlink:href` (SVG links)
---
#### FR-102: `outboundFormTracker` — Automatic Outbound Form Submit Tracking
**Description:**
Automatically detect when a form is submitted to an external domain and send a GA4 event.
**Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `formSelector` | `string` | `'form'` | CSS selector for forms |
| `shouldTrackOutboundForm` | `(form: HTMLFormElement, parseUrl: Function) => boolean` | action hostname !== location.hostname | Custom detection |
| `eventName` | `string` | `'outbound_form_submit'` | GA4 event name |
| `hitFilter` | `Function` | `undefined` | Filter/modify params |
**Acceptance Criteria:**
- [ ] Detects form submits where `form.action` points to an external domain
- [ ] Delays form submission briefly to ensure the GA4 event is sent
- [ ] Falls back gracefully if `navigator.sendBeacon` is not available
- [ ] Provides `remove()` method
---
#### FR-103: `pageVisibilityTracker` — Page Visibility Duration Tracking
**Description:**
Track how long a page is in the visible state vs. hidden (background tab). Optionally send a new `page_view` when the page becomes visible again after session timeout.
**Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `sendInitialPageview` | `boolean` | `false` | Plugin handles the initial page_view |
| `sessionTimeout` | `number` | `30` (minutes) | Minutes of hidden time before new session |
| `timeZone` | `string` | `undefined` | IANA timezone for session boundary |
| `pageLoadsMetricIndex` | `number` | `undefined` | Custom metric index |
| `visibleMetricIndex` | `number` | `undefined` | Custom metric for visible time |
| `eventName` | `string` | `'page_visibility'` | GA4 event name |
| `hitFilter` | `Function` | `undefined` | Filter/modify params |
**Default event parameters sent:**
| Parameter | Value |
|-----------|-------|
| `event_name` | `'page_visibility'` |
| `visibility_state` | `'visible'` or `'hidden'` |
| `visibility_duration` | Time in ms the page was in previous state |
| `page_path` | Current page path |
**Acceptance Criteria:**
- [ ] Listens for `visibilitychange` events on `document`
- [ ] Tracks cumulative visible time accurately
- [ ] Optionally sends new `page_view` on visible→hidden→visible session timeout
- [ ] Sends final visibility duration on `beforeunload`
- [ ] Provides `remove()` method
---
#### FR-104: `urlChangeTracker` — SPA URL Change Tracking
**Description:**
Automatically track URL changes in Single Page Applications by intercepting `history.pushState()`, `history.replaceState()`, and `popstate` events, sending a `page_view` event for each navigation.
**Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `shouldTrackUrlChange` | `(newPath: string, oldPath: string) => boolean` | `newPath !== oldPath` | Custom logic for what counts as a URL change |
| `trackReplaceState` | `boolean` | `false` | Whether `replaceState` triggers tracking |
| `hitFilter` | `Function` | `undefined` | Filter/modify params |
**Default event parameters sent:**
| Parameter | Value |
|-----------|-------|
| `event_name` | `'page_view'` |
| `page_path` | New URL path |
| `page_title` | `document.title` |
| `page_location` | Full URL |
**Acceptance Criteria:**
- [ ] Monkey-patches `history.pushState` and optionally `history.replaceState`
- [ ] Listens for `popstate` events (back/forward navigation)
- [ ] Sends `page_view` GA4 event on each tracked URL change
- [ ] Provides `shouldTrackUrlChange` callback for filtering
- [ ] Restores original `history.pushState`/`replaceState` on `remove()`
- [ ] Does not double-fire for the initial page load
---
#### FR-105: `impressionTracker` — Element Viewport Impression Tracking
**Description:**
Track when specific DOM elements become visible in the viewport using `IntersectionObserver`. Useful for tracking ad impressions, CTA visibility, etc.
**Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `elements` | `Array<string \| ElementConfig>` | `[]` | Element IDs or config objects to observe |
| `rootMargin` | `string` | `'0px'` | IntersectionObserver rootMargin |
| `attributePrefix` | `string` | `'data-ga4-'` | Attribute prefix for declarative params |
| `eventName` | `string` | `'element_impression'` | GA4 event name |
| `hitFilter` | `Function` | `undefined` | Filter/modify params |
**Element config object:**
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `id` | `string` | — | Element ID to observe |
| `threshold` | `number` | `0` | Visibility ratio (0-1) to trigger |
| `trackFirstImpressionOnly` | `boolean` | `true` | Only fire once per element |
**Acceptance Criteria:**
- [ ] Uses `IntersectionObserver` API to detect element visibility
- [ ] Uses `MutationObserver` to handle dynamically added/removed elements
- [ ] Supports per-element threshold configuration
- [ ] Supports `trackFirstImpressionOnly` option
- [ ] Provides `observeElements()`, `unobserveElements()`, `unobserveAllElements()` methods
- [ ] Feature-detects `IntersectionObserver` / `MutationObserver` — no-ops gracefully if unsupported
- [ ] Provides `remove()` method
---
#### FR-106: `cleanUrlTracker` — URL Normalization for page_view Events
**Description:**
Normalize URLs before they are sent with `page_view` events to ensure consistency in GA4 reports.
**Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `stripQuery` | `boolean` | `false` | Remove query string from URLs |
| `queryParamsAllowlist` | `string[]` | `undefined` | Query params to keep (when `stripQuery` is true) |
| `queryParamsDenylist` | `string[]` | `undefined` | Specific query params to remove |
| `trailingSlash` | `'add' \| 'remove'` | `undefined` | Normalize trailing slashes |
| `urlFilter` | `(url: string) => string` | `undefined` | Custom URL transformation function |
**Acceptance Criteria:**
- [ ] Strips query parameters when configured
- [ ] Supports allowlist/denylist for selective query param removal
- [ ] Normalizes trailing slashes
- [ ] Applies custom `urlFilter` function
- [ ] Applies transformations to `page_location` and `page_path` in page_view events
- [ ] Provides `remove()` method
---
#### FR-107: `mediaQueryTracker` — Responsive Breakpoint Tracking
**Description:**
Track which CSS media query breakpoints match and fire events when breakpoints change.
**Options:**
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `definitions` | `MediaQueryDefinition[]` | `[]` | Array of media query definitions |
| `changeTemplate` | `(oldValue: string, newValue: string) => string` | `'${oldValue} => ${newValue}'` | Template for change events |
| `changeTimeout` | `number` | `1000` | Debounce timeout in ms |
| `eventName` | `string` | `'media_query_change'` | GA4 event name |
| `hitFilter` | `Function` | `undefined` | Filter/modify params |
**MediaQueryDefinition:**
| Property | Type | Description |
|----------|------|-------------|
| `name` | `string` | e.g. `'Breakpoint'` |
| `dimensionIndex` | `number` | Custom dimension index |
| `items` | `Array<{name: string, media: string}>` | Media query items |
**Acceptance Criteria:**
- [ ] Uses `window.matchMedia()` API
- [ ] Fires event on breakpoint change with old and new values
- [ ] Debounces rapid changes (e.g. window resize)
- [ ] Feature-detects `matchMedia` — no-ops if unsupported
- [ ] Provides `remove()` method
---
## 5. Non-Functional Requirements
| ID | Requirement | Priority | Status |
|---------|--------------------------|----------|-------------|
| NFR-001 | Modern Browser Support | High | Not Started |
| NFR-002 | TypeScript Type Safety | High | Not Started |
| NFR-003 | Zero Runtime Dependencies | High | Not Started |
| NFR-004 | Tree-Shakeable Plugins | High | Not Started |
| NFR-005 | Bundle Size < 8KB gzip | Medium | Not Started |
| NFR-006 | Test Coverage >= 80% | Medium | Not Started |
| NFR-007 | Documentation per Plugin | Medium | Not Started |
| NFR-008 | Graceful Feature Detection | High | Not Started |
### 5.1 Details
- **Browser Compatibility:** Chrome 64+, Firefox 67+, Safari 12+, Edge 79+ (all browsers supporting `IntersectionObserver`, `MutationObserver`, `navigator.sendBeacon`)
- **TypeScript Version:** >= 4.x
- **Bundle Size Target:** < 8KB gzipped (all plugins), individual plugins < 2KB each
- **Test Coverage Target:** >= 80% line coverage
- **Graceful Degradation:** All plugins must feature-detect required browser APIs and silently no-op if unsupported (never throw errors)
---
## 6. Technical Requirements
- **Language:** TypeScript (strict mode)
- **Build Tool:** tsc
- **Test Framework:** Jest + jsdom
- **Package Registry:** npm (public, `@technoapple/ga4`)
- **Module Format:** CommonJS + ESM (dual publish)
- **Runtime Dependencies:** None (zero dependencies)
- **Node.js Version:** >= 16
---
## 7. Architecture & API Design
### 7.1 Existing APIs (unchanged)
| API | Method | Parameters | Returns | Description |
|-----|--------|------------|---------|-------------|
| `ga4` | `init` | `options: ga4Option` | `void` | Initialize GA4 with targetId |
| `ga4` | `send` | `event: string, params: KeyValueParams` | `boolean` | Send a GA4 event |
| `ga4` | `gtag` | (getter) | `gtag` | Direct access to `window.gtag` |
| `dataLayerHelper` | `get` | `key: string, getLast?: boolean` | `any` | Retrieve value from dataLayer |
### 7.2 New Plugin Architecture
Each plugin follows a consistent pattern:
```typescript
interface PluginInterface {
remove(): void; // Clean up all listeners, restore original state
}
```
**Plugin registration pattern (follows existing singleton pattern):**
```typescript
import { ga4, plugins } from '@technoapple/ga4';
// Initialize GA4 (existing)
ga4.init({ targetId: 'G-XXXXXXX' });
// Register plugins (new)
ga4.use(plugins.outboundLinkTracker, { /* options */ });
ga4.use(plugins.pageVisibilityTracker);
ga4.use(plugins.urlChangeTracker);
// Or import individual plugins directly
import { OutboundLinkTracker } from '@technoapple/ga4/plugins';
const tracker = new OutboundLinkTracker(ga4, { /* options */ });
tracker.remove(); // cleanup
```
### 7.3 Proposed File Structure
```
src/
index.ts # Main exports (existing + new)
ga4/
ga4.ts # Core GA4 class (existing, add .use() method)
ga4option.ts # Options interface (existing)
index.ts # GA4 barrel export (existing)
dataLayer.ts # DataLayer helper (existing)
util.ts # Utilities (existing, extend)
types/
dataLayer.ts # DataLayer types (existing)
global.ts # Window augmentation (existing)
gtag.ts # gtag type definitions (existing)
plugins.ts # Plugin option types (new)
plugins/
index.ts # Barrel export for all plugins
plugin-base.ts # Base class / interface for plugins
event-tracker.ts # FR-100
outbound-link-tracker.ts # FR-101
outbound-form-tracker.ts # FR-102
page-visibility-tracker.ts # FR-103
url-change-tracker.ts # FR-104
impression-tracker.ts # FR-105
clean-url-tracker.ts # FR-106
media-query-tracker.ts # FR-107
helpers/
delegate.ts # Event delegation utility
parse-url.ts # URL parsing utility
dom-ready.ts # DOM ready utility
session.ts # Session timeout / storage utilities
debounce.ts # Debounce utility
test/
dataLayer.spec.ts # Existing
ga4.spec.ts # Existing
plugins/
event-tracker.spec.ts
outbound-link-tracker.spec.ts
outbound-form-tracker.spec.ts
page-visibility-tracker.spec.ts
url-change-tracker.spec.ts
impression-tracker.spec.ts
clean-url-tracker.spec.ts
media-query-tracker.spec.ts
helpers/
delegate.spec.ts
parse-url.spec.ts
session.spec.ts
```
### 7.4 Internal Utilities
Lightweight internal helpers to keep zero runtime dependencies:
| Utility | File | Description |
|---------|------|-------------|
| `delegate` | `helpers/delegate.ts` | Event delegation using `document.addEventListener` + selector matching |
| `parseUrl` | `helpers/parse-url.ts` | Create an `<a>` element to parse URLs (returns `Location`-like object) |
| `domReady` | `helpers/dom-ready.ts` | Wait for DOM `DOMContentLoaded` |
| `sessionManager` | `helpers/session.ts` | `sessionStorage`-based session tracking with configurable timeout |
| `debounce` | `helpers/debounce.ts` | Standard debounce implementation |
---
## 8. User Stories
| ID | Story | Priority |
|-------|-------|----------|
| US-01 | As a developer, I want to track outbound link clicks automatically so I can see which external sites users navigate to | High |
| US-02 | As a developer, I want to track page visibility so I can measure actual engagement time | High |
| US-03 | As a developer, I want SPA URL changes to automatically send page_view events so my GA4 reports are accurate | High |
| US-04 | As a content author, I want to add tracking via HTML data attributes without writing JavaScript | High |
| US-05 | As a developer, I want to track when specific elements (ads, CTAs) become visible in the viewport | Medium |
| US-06 | As a developer, I want to track outbound form submissions so I don't lose visibility when users are sent to external payment/signup pages | Medium |
| US-07 | As a developer, I want to normalize page URLs in my tracking to avoid fragmented data in GA4 reports | Medium |
| US-08 | As a developer, I want to track which responsive breakpoints are active so I can correlate device size with behavior | Low |
| US-09 | As a developer, I want to only import the plugins I need so my bundle size stays small | High |
---
## 9. Constraints & Assumptions
### Constraints
- Must not introduce any third-party runtime dependencies
- Must work in browser environments only (`window`, `document` required)
- Must be backward compatible — existing `ga4.init()`, `ga4.send()`, `ga4.gtag`, `dataLayerHelper.get()` APIs must not change
- Each plugin must be independently importable (tree-shakeable)
- All DOM event listeners must be removable (no leaks)
### Assumptions
- `window` and `document` are available (browser environment)
- GA4's `gtag.js` script is loaded by the consumer (or `ga4.init()` has been called)
- Consumers use modern browsers (no IE11 support needed)
- `sessionStorage` is available for session-based tracking
---
## 10. Risks & Mitigations
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Google changes gtag.js API | High | Low | Use documented public API only; abstract `gtag()` calls through our `ga4.send()` |
| IntersectionObserver not available in older browsers | Medium | Low | Feature detection — impressionTracker silently no-ops |
| Monkey-patching `history.pushState` conflicts with other libraries (e.g. React Router) | Medium | Medium | Carefully chain the original function; test with popular routers |
| `sendBeacon` not available | Low | Low | Fallback to synchronous XHR or `_blank` target trick |
| `sessionStorage` quota exceeded or disabled | Low | Low | Wrap in try-catch, degrade gracefully |
---
## 11. Implementation Order & Milestones
Plugins are ordered by priority and dependency:
| Phase | Milestone | Plugins / Tasks | Target Date | Status |
|-------|-----------|-----------------|-------------|--------|
| 0 | Core infrastructure | `helpers/` utilities, plugin base class, `ga4.use()` method | TBD | Not Started |
| 1 | High-priority plugins | `eventTracker`, `outboundLinkTracker` | TBD | Not Started |
| 2 | SPA & visibility | `urlChangeTracker`, `pageVisibilityTracker` | TBD | Not Started |
| 3 | Remaining plugins | `impressionTracker`, `outboundFormTracker`, `cleanUrlTracker` | TBD | Not Started |
| 4 | Low-priority | `mediaQueryTracker` | TBD | Not Started |
| 5 | Polish | Documentation, README update, examples | TBD | Not Started |
| 6 | Release | npm publish v2.0.0 | TBD | Not Started |
---
## 12. Sign-Off
| Role | Name | Date | Approved |
|----------------|------|------|----------|
| Product Owner | | | [ ] |
| Tech Lead | | | [ ] |
| QA | | | [ ] |
---
_This document is a living artifact. Update it as requirements evolve._