Skip to content

feat(analytics): send a hashed user id to GA and server-render the GTM snippet - #934

Merged
thostetler merged 4 commits into
adsabs:masterfrom
thostetler:feat/ga-user-id
Sep 23, 2026
Merged

thostetler merged 4 commits into
adsabs:masterfrom
thostetler:feat/ga-user-id

Conversation

@thostetler

@thostetler thostetler commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

GTM flagged the container tag as incorrectly placed because it rendered client-side only, and Nectar sent no user id at all, so logged-in traffic couldn't be segmented the way Bumblebee segments it.

  • Inlines the GTM snippet into _document's , gated on NEXT_PUBLIC_GTM_ID, instead of rendering GoogleTagManager client-side in
  • Seeds a hashed user_id into dataLayer server-side, ahead of gtm.start, so the tag's first fire carries it
  • Adds useTrackUserId to cover login and logout within an already-loaded page, deduped against the 5-minute token refresh
  • Hash matches Bumblebee's, so one account maps to one GA User-ID across both frontends

Known limitation: username is the user's editable email, so an email change mints a new GA User-ID, same as Bumblebee today. Companion PR adsabs/bumblebee#2381 clears the id on logout.

@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Bundle size

Shared by all pages: 624.6 kB (-0.0 kB) ⚪

No route changed by more than 1 kB. ✅

First load = polyfills + shared _app chunks + route chunks, gzipped.

@codecov

codecov Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 68.0%. Comparing base (d6e8ab2) to head (4525282).

Additional details and impacted files
@@           Coverage Diff            @@
##           master    #934     +/-   ##
========================================
+ Coverage    67.9%   68.0%   +0.2%     
========================================
  Files         379     381      +2     
  Lines       44188   44185      -3     
  Branches     2380    2399     +19     
========================================
+ Hits        29974   30039     +65     
+ Misses      14165   14098     -67     
+ Partials       49      48      -1     
Files with missing lines Coverage Δ
src/gtm-snippet.ts 100.0% <100.0%> (ø)
src/lib/useTrackUserId.ts 100.0% <100.0%> (ø)
src/test-utils.tsx 88.1% <100.0%> (-0.3%) ⬇️
src/utils/session-identity.ts 100.0% <100.0%> (ø)

... and 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@thostetler
thostetler marked this pull request as ready for review September 16, 2026 16:48
Copilot AI lite review requested due to automatic review settings September 16, 2026 16:48

Copilot AI left a comment

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.

🟡 Changes recommended

Web Crypto test setup, expiry handling and coverage, and hash-failure state clearing remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds SHA-256 hashed user-ID tracking for authenticated GA sessions across login, hydration, refresh, and logout.

Changes:

  • Adds and mounts useTrackUserId.
  • Emits deduplicated GTM user_update events.
  • Adds tracking tests for authentication and logout behavior.
File summaries
File Summary
src/providers.tsx Mounts user-ID telemetry in Telemetry.
src/lib/useTrackUserId.ts Implements hashed user-ID tracking and session gating.
src/lib/useTrackUserId.test.ts Tests tracking, deduplication, and logout behavior.
Review details

Suppressed comments (5)

src/lib/useTrackUserId.test.ts:47

  • [medium] The new token-expiry gate is not covered: the tests exercise anonymous and valid-token users, but never assert that a non-anonymous user with an expired expires_at produces no hashed event. Since avoiding tracking a rehydrated dead session is a stated behavior of this hook, add an expired-session case so a regression is caught. Confidence: high.
  test('sends nothing for an anonymous session', async () => {
    user = { ...authed('anonymous@ads'), anonymous: true };
    renderHook(() => useTrackUserId());

    await waitFor(() => expect(sendGTMEvent).not.toHaveBeenCalled());
  });

src/lib/useTrackUserId.ts:45

  • [medium] Impact: if hashing fails after a user ID was already sent (for example during a user switch or a transient Web Crypto failure), lastSentRef remains the old hash and GA continues attributing subsequent events to the wrong account. Clear the tracked ID and emit the null update when the active hash cannot be produced, then allow a later refresh to retry. Confidence: high.
      .catch((err: unknown) => {
        logger.error({ err }, 'useTrackUserId: hash error');
      });

src/lib/useTrackUserId.ts:23

  • This validity check only runs when the user object changes, so it does not react when expires_at passes. If the periodic /api/user refresh returns an invalid session, UserSync leaves the previous valid store user in place (src/pages/_app.tsx:187-192), and the previously sent hash remains active; subsequent anonymous traffic can therefore stay attributed to an expired session. Clear the tracking state when the session expires or arrange for invalid refresh results to clear the store.
    const username = isValidToken(user) && !user.anonymous ? user.username : null;

src/lib/useTrackUserId.ts:23

  • Impact: an invalid persisted session can still emit a user ID. isValidToken ultimately compares currentTime >= parseInt(expires_at, 10), so a non-numeric but non-empty expires_at becomes NaN and is treated as not expired; this line will then hash/send the username even though the expiry is unusable. Please make the shared expiry validation require a finite numeric timestamp (or add an equivalent guard before tracking). Confidence: high.
    const username = isValidToken(user) && !user.anonymous ? user.username : null;

src/lib/useTrackUserId.ts:23

  • The expiry check is the key protection for rehydrated sessions, but the added tests cover only valid, anonymous, token-refresh, and undefined users. Add an expired-token case that asserts no user_update is emitted (and covers the transition from a previously tracked user), so a regression in this new privacy-sensitive gate is caught. Confidence: high.
    const username = isValidToken(user) && !user.anonymous ? user.username : null;
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/lib/useTrackUserId.test.ts
@thostetler thostetler changed the title feat(analytics): send a hashed user id to GA for logged-in sessions feat(analytics): send a hashed user id to GA and server-render the GTM snippet Sep 16, 2026
GoogleTagManager rendered inside <Layout> via next/script at the default
afterInteractive strategy, which only runs client-side in the pages
router, so GTM flagged the tag as incorrectly placed on / and /abs/*.
Inlines Google's snippet as the first child of <Head> in _document,
gated on NEXT_PUBLIC_GTM_ID.
Bumblebee tags the GA session with a hashed user id after login; Nectar
sent nothing.

useTrackUserId sends a user_update GTM event with the SHA-256 hash of
the username on login, clears it on logout, and dedupes against the
5-minute token refresh. The hash matches Bumblebee's, so one account
maps to one GA User-ID across both frontends.
The Google tag fires from <head> before React mounts, so the client
hook's push always lost the race and page_view carried no User-ID.

getGtmUserId hashes the username server-side, from the session data
injectSessionGSSP already puts in __NEXT_DATA__, and getGtmSnippet
seeds it ahead of gtm.start.
Copilot review flagged that isValidToken parses expires_at with parseInt
and compares with >=, so a malformed expiry compares false against NaN
and reads as "not expired" — defeating the gate that stops a rehydrated
dead session emitting an id.

- isTrackableSession replaces the inline checks in both the hook and the
  server seed; standalone rather than built on isValidToken, which also
  closes a circular import through api.ts
- Resets the remembered hash when hashing fails, so GA stops attributing
  hits to whoever was last hashed
- Covers expired, unparseable and empty expires_at, which had no tests
@thostetler
thostetler merged commit d77e8fb into adsabs:master Sep 23, 2026
6 checks passed
@thostetler
thostetler deleted the feat/ga-user-id branch September 23, 2026 17:32
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.

3 participants