Hire Conversion Tracking
apps/hire reports five funnel events to Google Tag Manager. GTM turns them
into GA4 events and Google Ads conversions. This page is the runbook for the
console-side configuration, which lives outside the repository and cannot be
recreated from the code alone.
Events
Section titled “Events”Everything is pushed onto window.dataLayer by apps/hire/lib/analytics.ts.
| Event | Fires when | Parameters |
|---|---|---|
hire_query_landing | A visitor lands with a non-empty ?q= (or legacy ?query=) | query_length |
hire_email_submitted | A work email is accepted and a code is sent | chat_id, user_data |
hire_email_verified | The code is verified and the session is created | chat_id, user_data |
hire_candidates_shown | Candidate results are first shown for a chat | chat_id, candidate_count, is_broadened, user_data |
hire_booking_opened | The contact request succeeds and the Cal.com embed appears | chat_id, user_data |
The GTM snippet also pushes landing_page_location and
landing_page_referrer before anything else. The chat hook rewrites the URL
to /chat/<id> as soon as it has read ?q=, dropping gclid, _gl and
every utm_* with it; gtm.js loads asynchronously and may not have run by
then, so that snapshot is the only copy of the landing URL the container can
rely on.
Two rules the code enforces
Section titled “Two rules the code enforces”No raw PII reaches the dataLayer. The ?q= text is free-form user input,
so only its length is reported. An email only ever travels as
user_data.sha256_email_address — the SHA-256 hex digest of the trimmed,
lowercased address, which is the form Google Ads enhanced conversions expect.
Analytics can never break a chat. Every push no-ops when GTM has not
created a dataLayer, and swallows its own errors. crypto.subtle is only
available in a secure context, so on a plain-http origin events still fire —
just without user_data.
Environment
Section titled “Environment”| Where | Name | Notes |
|---|---|---|
GitHub environments production / staging | HIRE_GTM_ID | A variable, not a secret — a container id ships in the HTML |
| Railway (set by CI) | NEXT_PUBLIC_GTM_ID | Renamed on the way through .github/workflows/hire-deploy.yml |
| Local | NEXT_PUBLIC_GTM_ID | Optional; see apps/hire/.env.example |
Leaving it unset loads no container at all — no script, no noscript iframe,
no dataLayer. That is what keeps local dev and staging out of the production
property.
Two things to remember:
NEXT_PUBLIC_*is inlined at build time. ChangingHIRE_GTM_IDrequires a redeploy, not just a variable edit. It works at all only because CI sets Railway variables beforerailway uptriggers the build.- A value that is not shaped like
GTM-XXXXXXXis rejected and the container is simply not loaded, rather than interpolated into the inline script.
GTM container
Section titled “GTM container”Use the same container as the marketing site (teros-dev/www, which reads
PUBLIC_GTM_ID). Cross-domain measurement, the GA4 configuration tag, and the
Ads tags then live in one place instead of being duplicated and drifting.
- Variables — Data Layer Variables for
chat_id,candidate_count,is_broadened,query_length, anduser_data(as one object). - Triggers — one Custom Event trigger per event name above, matched exactly.
- Tags — a Google tag (GA4) on initialisation; one GA4 Event tag per
trigger; one Google Ads Conversion Tracking tag per conversion action, each
with Enhanced Conversions on and its User-Provided Data variable pointed at
user_data; a Conversion Linker on all pages. - Cross-domain — in the Google tag’s configuration settings list
teros.dev,www.teros.dev, andhire.teros.dev.
Verify in Preview mode against both domains before publishing a version.
- Admin → Data Streams → Configure tag settings → Configure your domains:
add
teros.dev,www.teros.dev,hire.teros.dev. Without this the hop from the marketing site starts a new session attributed to a referral. - Custom definitions: register
chat_id,candidate_count,is_broadened, andquery_lengthas event-scoped dimensions/metrics, or they are collected but not reportable. - Key events: mark
hire_email_submitted,hire_email_verified,hire_candidates_shown, andhire_booking_opened.hire_query_landingstays a plain event — it is funnel entry, not a conversion.
Google Ads
Section titled “Google Ads”Three conversion actions, fired by the GTM tags:
| Conversion action | Source event | Category | Primary? |
|---|---|---|---|
| Hire — Email Submitted | hire_email_submitted | Submit lead form | Secondary |
| Hire — Candidates Shown | hire_candidates_shown | Page view | Secondary |
| Hire — Talk To Team | hire_booking_opened | Book appointment | Primary |
Only the primary should drive bidding; the others exist to show where the funnel leaks. Set counting to “One” on all three — one visitor submitting twice is one lead.
Turn on Enhanced conversions for web with source “Google Tag Manager” and accept the customer-data terms.
Do not also import the same GA4 key events as Ads conversion actions. Running GTM-fired Ads tags and GA4 imports for the same moment double-counts every conversion. The GTM tags are the ones that carry the hashed email.
Cross-domain hand-off
Section titled “Cross-domain hand-off”The marketing site sends visitors to https://hire.teros.dev/?q=... from
src/components/home/RoleInputHero.astro. That navigation must forward
gclid, gbraid, wbraid and any utm_* from the current URL, because a
programmatic window.location.href assignment is not decorated by GA4’s
automatic cross-domain linker. Without it, the ad click is lost at the domain
boundary even though both domains report to the same property.
Known gap
Section titled “Known gap”Google Consent Mode v2 is not implemented. There is no consent banner and no denied-by-default signalling, which leaves an EEA compliance gap. Track it separately.