Skip to content

feat: analytics auto-tracking helpers for Web and Flutter#1622

Open
lohanidamodar wants to merge 4 commits into
mainfrom
feat/analytics-auto-tracking
Open

feat: analytics auto-tracking helpers for Web and Flutter#1622
lohanidamodar wants to merge 4 commits into
mainfrom
feat/analytics-auto-tracking

Conversation

@lohanidamodar

@lohanidamodar lohanidamodar commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

Adds hand-written auto-tracking helpers for the Analytics service across the Web and Flutter SDKs, following the same pattern as Realtime (fully hand-written per platform, not generated from OpenAPI spec).

The helpers are composition-based: each takes a plain event-emitter callback rather than importing the generated service class directly, so they render and compile independently of any given spec revision. Users wire them up in one line:

const analytics = new Analytics(client);
const tracking = new AnalyticsTracking(
    (name, options) => analytics.event({ name, ...(options ?? {}) })
);
tracking.enableAllAutoTracking();

What's included

Web (templates/web/src/services/analytics-tracking.ts.twig)

  • enableAutoPageviews() / disableAutoPageviews() — SPA-aware: initial load + pushState / replaceState / popstate / hashchange
  • enableAutoOutboundTracking(options?) — click detection on external anchor links, optional subdomain handling
  • enableAutoDownloadTracking(options?) — click detection on file-download links, extension list configurable
  • enableAutoScrollDepth() — max scroll % per page, flushed on unload and on SPA route change
  • enableAutoEngagementTime() — active-time counter with visibilitychange gating
  • enableAllAutoTracking() — opinionated default set (pageviews, outbound, scroll, engagement)
  • track(name, options) / pageview(url, referrer) — manual passthroughs
  • All helpers respect navigator.doNotTrack by default (opt-out via respectDoNotTrack: false)

Flutter (templates/flutter/lib/src/analytics_observer.dart.twig, analytics_tracking.dart.twig)

  • AnalyticsObserver extends NavigatorObserver — auto screen-view tracking on route push / pop / replace / remove, with an optional ScreenNameExtractor for filtering
  • AnalyticsTrackingWidgetsBindingObserver-driven lifecycle events (app_backgrounded / app_foregrounded)
  • screenView(name, {className, props}) — mobile-idiomatic convenience for the pageview equivalent
  • enableAutoLifecycleEvents() / disableAutoLifecycleEvents() / enableAllAutoTracking()
  • Emitter exceptions are swallowed inside the observer and tracking class so analytics failures cannot crash the host app

Registrations

  • src/SDK/Language/Web.php — new template listed in getFiles()
  • src/SDK/Language/Flutter.php — both new templates listed in getFiles()
  • templates/web/src/index.ts.twigAnalyticsTracking and its supporting types re-exported
  • templates/flutter/lib/package.dart.twig — observer and tracking modules re-exported

Naming conventions

Auto-emitted event names and prop keys follow a single convention on both platforms so they compose cleanly with the analytics endpoint's parameter naming:

  • Event namessnake_case + lowercase. The helpers own the names of the events they emit themselves: pageview, outbound_link, file_download, scroll_depth, engagement_time (Web), screen_view, app_backgrounded, app_foregrounded (Flutter). Callers can still pass any custom event name to the manual track() / event() methods; those strings are forwarded verbatim.
  • Prop keyscamelCase, matching the endpoint's top-level parameter naming (hostname, href, filename, extension, screenClass, foregroundSeconds, etc.).
  • Top-level endpoint fields on WebAnalyticsEventOptions exposes scrollDepth and engagementTime as first-class option fields so the scroll-depth and engagement-time trackers forward them as endpoint params rather than folding them into props.
  • Typedef parity — the Flutter emitter typedef is AnalyticsEventEmitter (was AnalyticsEventEmit), matching the Web SDK's exported name for cross-platform consistency.

Notes

  • The helpers reference the generated Analytics service only through user-supplied callbacks, so this PR is safe to merge before / after the spec adds the ingestion endpoint. Once the spec exposes analytics.event(...), the wiring shown above is the intended usage.
  • No browser vs. IO platform split for Flutter — Analytics doesn't have a WebSocket-vs-IO concern like Realtime does. Single file per feature.
  • Server SDKs (Node / PHP / Python / etc.) already get analytics.event(...) from the standard generated code path, so no hand-written helper is added there.

Verification

  • composer test — 33/33 unit tests pass (vendor/bin/phpunit --testsuite Unit)
  • composer refactor:check — clean (rector, dry run)
  • composer lint-twig — clean (djlint over all 565 template files)
  • composer lint — clean (phpcs)
  • Web SDK: regenerated against the shipped test spec; tsc --noEmit reports zero errors in the new analytics-tracking.ts (pre-existing errors in other generated services are unrelated)
  • Flutter SDK: regenerated against the shipped test spec; flutter analyze lib/src/analytics_observer.dart lib/src/analytics_tracking.dart reports zero issues

Adds a hand-written companion to the generated `Analytics` service that wires
up common auto-tracking behaviours (pageviews, outbound links, downloads,
scroll depth, active engagement) on top of the standard `event()` endpoint.

The helper is composition-based and takes a plain event-emitter callback so
it does not hard-couple to the generated service class. All helpers respect
`navigator.doNotTrack` by default and can be opted out.

Public surface:

- `AnalyticsTracking` — main entry point, wraps an emitter callback
- `enableAutoPageviews()` / `disableAutoPageviews()` — SPA-aware pageview
  tracking that hooks `pushState`, `replaceState`, `popstate` and hashchange
- `enableAutoOutboundTracking(options?)` — anchor-click detection for
  external links with optional subdomain handling
- `enableAutoDownloadTracking(options?)` — anchor-click detection by file
  extension list
- `enableAutoScrollDepth()` — max scroll % per page, flushed on unload and
  SPA route change
- `enableAutoEngagementTime()` — active-time counter with visibility gating
- `enableAllAutoTracking()` — opinionated default set (pageviews, outbound,
  scroll, engagement)
- `track(name, options)` / `pageview(url, referrer)` — manual passthroughs

The new template file is registered in `Web.getFiles()` and the class is
re-exported from the SDK entry point.
Adds hand-written companions to the generated `Analytics` service for the
Flutter SDK, following the same composition-based pattern as the Web helpers.

The observer plugs into `MaterialApp.navigatorObservers` and fires an event
for every route push/pop/replace/remove; the tracking class listens to
`WidgetsBindingObserver` for app-backgrounded / app-foregrounded events and
exposes a mobile-idiomatic `screenView(name, {className, props})` shortcut.

Public surface:

- `AnalyticsEventEmit` typedef — the callback used to send events, keeping
  the helpers decoupled from the generated service class
- `AnalyticsObserver extends NavigatorObserver` — auto screen-view tracking
  with an optional `ScreenNameExtractor`
- `AnalyticsTracking` — `WidgetsBindingObserver`-driven lifecycle events,
  plus `screenView()` and `event()` shortcuts and an `enableAllAutoTracking()`
  composite

The new templates are registered in `Flutter.getFiles()` and re-exported from
the package entry point.
@lohanidamodar lohanidamodar marked this pull request as ready for review July 6, 2026 02:58
@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds hand-written auto-tracking helpers for the Analytics service to the Web and Flutter SDKs, following the same composition pattern used by the Realtime helpers. The helpers accept an event-emitter callback rather than coupling to the generated Analytics class directly, making them forward-compatible with spec changes.

  • Web (analytics-tracking.ts): Adds SPA-aware pageview tracking, outbound-link and file-download click detection, scroll-depth reporting, and active engagement-time counting \u2014 each with a matching disable counterpart and DNT opt-out support.
  • Flutter (analytics_observer.dart, analytics_tracking.dart): Adds a NavigatorObserver for route screen-view events and a WidgetsBindingObserver-backed lifecycle tracker (app_backgrounded / app_foregrounded); both swallow emitter exceptions to prevent analytics failures from crashing the host app.
  • Registration entries added to Web.php and Flutter.php, and re-exports added to the respective barrel files.

Confidence Score: 4/5

Safe to merge with one logic fix: the engagement timer starts unconditionally without checking initial tab visibility, which corrupts engagement metrics for background-tab opens.

The Web engagement tracker calls startEngagement() at setup time without checking document.visibilityState. Pages opened in background tabs will have the timer running before the user ever sees the page, and visibilitychange won't fire to pause it until the state changes. The Flutter side and all registration changes look correct.

templates/web/src/services/analytics-tracking.ts.twig — specifically the enableAutoEngagementTime method around line 388.

Important Files Changed

Filename Overview
templates/web/src/services/analytics-tracking.ts.twig New 563-line hand-written analytics helper. Core logic is sound but the engagement timer unconditionally starts without checking initial visibility state, inflating metrics when pages are opened in background tabs.
templates/flutter/lib/src/analytics_tracking.dart.twig New lifecycle-tracking helper. Correctly calls WidgetsFlutterBinding.ensureInitialized() and guards _foregroundSince to prevent double app_backgrounded events on desktop/web.
templates/flutter/lib/src/analytics_observer.dart.twig New NavigatorObserver for auto screen-view tracking. Emitter exceptions are correctly swallowed, and route name extraction defaults are clean.
src/SDK/Language/Web.php Registers the new analytics-tracking template in the Web SDK file list. Minimal and consistent with the Realtime registration pattern.
src/SDK/Language/Flutter.php Registers both new Flutter analytics templates in the file list. Pattern mirrors existing hand-written SDK files.
templates/web/src/index.ts.twig Re-exports AnalyticsTracking and all supporting types; consistent with how Realtime and its types are exported.
templates/flutter/lib/package.dart.twig Adds two export lines for the new observer and tracking modules; consistent with existing package barrel structure.

Reviews (3): Last reviewed commit: "fix(analytics): stop engagement timer on..." | Re-trigger Greptile

Comment on lines +412 to +418
private rootDomain(hostname: string): string {
const parts = hostname.split('.');
if (parts.length <= 2) {
return hostname;
}
return parts.slice(-2).join('.');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 rootDomain breaks for multi-part TLDs

parts.slice(-2).join('.') returns co.uk for both myapp.co.uk and google.co.uk, so any click from a .co.uk site (or .com.au, .com.br, .co.jp, etc.) to any other domain on the same TLD registers as internal and is silently dropped from outbound tracking. Concretely, from shop.example.co.uk, a click on https://google.co.uk returns rootDomain = co.uk on both sides, isExternal returns false, and no event is fired.

Comment thread templates/flutter/lib/src/analytics_tracking.dart.twig
Comment thread templates/web/src/services/analytics-tracking.ts.twig
Comment on lines +177 to +182
this.pageviewCleanups.push(() => {
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
window.removeEventListener('popstate', popstateHandler);
window.removeEventListener('hashchange', hashchangeHandler);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Restoring monkey-patched history methods can clobber third-party router patches

disableAutoPageviews() restores history.pushState and history.replaceState to the references captured at enableAutoPageviews() call time. If a router (Next.js App Router, React Router, etc.) patched the same methods after this tracker initialised, the restore will silently discard that library's wrapper. The typical pattern for chaining patches is to keep the wrapped function and call through to it; restoring to a saved reference is safer only when no other library ever patches these globals.

Comment thread templates/web/src/services/analytics-tracking.ts.twig
Auto-emitted event names and prop keys now align with the analytics
endpoint's canonical patterns:

- Event names are snake_case + lowercase (`pageview`, `outbound_link`,
  `file_download`, `scroll_depth`, `engagement_time`, `screen_view`,
  `app_backgrounded`, `app_foregrounded`) rather than PascalCase strings
  with parametric suffixes. Data that was previously encoded into the
  event name (hostname for outbound links, filename for downloads) now
  moves into camelCase prop fields.
- Web: add optional `scrollDepth` and `engagementTime` fields on
  `AnalyticsEventOptions` so the scroll-depth and engagement-time
  auto-trackers forward those values as top-level endpoint params rather
  than folding them into `props`.
- Flutter: rename `AnalyticsEventEmit` -> `AnalyticsEventEmitter` for
  cross-platform typedef consistency with the Web SDK; rename the
  `screenView` `class` prop to `screenClass` so it follows the camelCase
  convention.
- Docblocks on both platforms document the naming convention.
Comment thread templates/web/src/services/analytics-tracking.ts.twig
Comment thread templates/flutter/lib/src/analytics_tracking.dart.twig
…ng before observer

Address two bugs surfaced by review.

Web: `flushEngagement` called `startEngagement()` at the end of every flush,
including the pagehide flush that precedes back-forward-cache entry. When
the tab was restored via bfcache the timer resumed with an engagementStart
snapshotted before the freeze, so the next flush billed the entire bfcache
dwell time as engagement. flushEngagement no longer auto-restarts; restart
is now driven by explicit resume signals — pageshow with persisted === true,
visibilitychange back to visible, or the SPA navigation flow.

Flutter: `enableAutoLifecycleEvents()` touched `WidgetsBinding.instance`
without ensuring the binding, so callers who followed the docstring example
(construct + `enableAllAutoTracking()` before `runApp`) hit `StateError:
Binding has not yet been initialized`. Call `WidgetsFlutterBinding.ensureInitialized()`
before attaching the observer (idempotent).

Also fold in a few smaller review findings:

- Flutter: guard `app_backgrounded` on `_foregroundSince` so it emits once
  per background transition instead of firing a second empty event when the
  desktop/web lifecycle passes through both `hidden` and `paused`.
- Web: drop image extensions (jpg/jpeg/png/gif/svg/webp) from the default
  download list — anchor-wrapped inline images (lightboxes, galleries)
  were being counted as file downloads.
- Web: add `disableAutoOutboundTracking`, `disableAutoDownloadTracking`,
  `disableAutoScrollDepth`, `disableAutoEngagementTime` for parity with
  `disableAutoPageviews`, so consent flows can tear down individual
  trackers without discarding the whole `AnalyticsTracking` instance.
@lohanidamodar

Copy link
Copy Markdown
Member Author

Thanks for the careful review — both P1s addressed, plus a few smaller items.

Required fixes

  • bfcache engagement inflation: flushEngagement no longer auto-restarts the timer. Restart is now driven by explicit resume signals — pageshow with persisted === true, visibilitychange back to visible, or the SPA-navigation flow. Frozen bfcache dwell no longer counts as active engagement.
  • Flutter binding init: WidgetsFlutterBinding.ensureInitialized() now runs inside enableAutoLifecycleEvents() before the observer is attached, so callers following the docstring example don't hit StateError: Binding has not yet been initialized. Docstring updated to note the binding is ensured internally.

Smaller cleanups folded in

  • Flutter app_backgrounded double-fire: guarded on _foregroundSince, so it emits once per background transition on desktop/web where the lifecycle passes through both hidden and paused.
  • Image extensions in default downloads: removed jpg/jpeg/png/gif/svg/webp — anchor-wrapped inline images (lightboxes, galleries) were being counted as file downloads.
  • Missing disable counterparts: added disableAutoOutboundTracking, disableAutoDownloadTracking, disableAutoScrollDepth, disableAutoEngagementTime for parity with disableAutoPageviews, so consent flows can tear down individual trackers without discarding the whole AnalyticsTracking instance.

Not tackled in this pass

  • rootDomain multi-part TLDs (.co.uk, .com.au, …): a correct fix needs a Public Suffix List lookup, which is a meaningful dependency / bundle-size decision worth its own change.
  • Monkey-patched history restoration clobbering third-party router patches: the fix is to keep and chain wrapped functions rather than restore saved refs, which changes the tear-down contract; happy to iterate in a follow-up.

Verified: PHPUnit unit suite passes, Rector dry-run clean, djLint clean, phpcs clean, regenerated Web SDK compiles with no tsc errors in analytics-tracking.ts, flutter analyze on the regenerated Flutter file reports no issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant