Identity and zQA Filters

User profiles, guest IDs, admin and QA classification, and PostHog filter setup

What is collected

Email and username are intentional person properties, not general event metadata. Only verified Auth responses and the secret-protected Auth-to-App ingest may supply them. Neither a browser event nor an account selector can assert admin status or change the actor's identity.

Person propertySource and meaning
user_id, zooly_user_idCanonical Zooly identityId, falling back to the authenticated ID. Also used as PostHog distinct_id. Never an email.
emailAuth/Cognito email; a real guest identity may supply its stored guest email. Unavailable email is null, not fabricated.
usernameZooly display name. Zooly does not currently have a separate user handle. Account slug remains account_slug.
guest_idOriginal Auth guest identity ID, retained after linking; otherwise a domain-separated SHA-256 pseudonym of the anonymous room-access cookie, when one exists.
is_guestTrue for an actual guest identity; false after authentication/linking. Anonymous browsing does not by itself create a person profile.
is_adminVerified Auth admin role.
identity_is_zqaAuth zQA role or isQaEmail: any @zooly.ai address, a name+tag@gmail.com alias, or a UUID-local-part QA inbox. Uses the verified Auth email, never browser-supplied traits.
account_is_zqaThe actor's own, server-resolved account has data_tag = 'zQA'.
is_zqaidentity_is_zqa OR account_is_zqa.
internal_useris_admin OR is_zqa.

The room-access cookie is a credential, not an analytics ID. Its raw value is never sent to PostHog. A guest pseudonym represents browser/room continuity, not proof of the same human on a shared device. It is never used to merge identified people. A visitor with no guest cookie/identity simply has PostHog's anonymous ID and a journey session ID.

Identity lifecycle

  1. Anonymous browser and server events share the browser's PostHog distinct ID when available. Server events use $process_person_profile: false until a canonical user is known.
  2. /api/me verifies the Auth session. App's /api/analytics/identity additionally resolves the actor's own account tag and guest pseudonym. These responses use private, no-store.
  3. posthog.identify(canonicalId, properties) connects the anonymous browser to the authenticated person. Email signup, social redirects and the modal/popup completion paths refresh identity.
  4. Tab focus/visibility refreshes properties, including role/tag removal. A transient Auth/database error does not clear a known identity.
  5. Successful logout resets PostHog and the first-party journey session. Switching from identified person A to B resets first; it never aliases A to B. A stale in-flight identity response cannot undo logout.

Auth alone does not know the account tag and therefore does not overwrite account_is_zqa, is_zqa or internal_user. App owns those combined flags. Existing guest-to-account linking preserves the canonical Auth ID; do not add arbitrary aliases from query parameters.

Anonymous replay can start immediately under the existing consent policy. If the browser persists an identified person, replay and the shared analytics context wait for identity verification to avoid attributing a different user's initial page to that person.

Person versus target account

A real person opening a zQA Z-Link stays a real person. The target's classification is recorded separately as target_account_is_zqa; it never sets that visitor's person-level internal_user. Account and agency groups also expose their own is_zqa property; group tags are not person tags.

Events expose actor/target account dimensions and data_tag. A trusted analytics_identity envelope stores non-PII event-time flags in the first-party database. The envelope is stripped before PostHog capture and only its allowlisted fields are flattened. Email and username are not stored in this envelope.

Fresh authenticated deliveries can update PostHog person properties through $set. Backend-only events and retries resolve the current profile through Auth's server-only /api/internal/analytics/identity endpoint, using the existing environment-specific journey-ingest secret. This covers real guest identities even without a browser login. The lookup has a three-second timeout and fails open for event delivery, not for identity enrichment: an outage leaves unknown properties unset.

Retried historical rows do not overwrite current person properties with old role/tag values. They may refresh traits only from the trusted live lookup; otherwise they set stable user IDs only. Opening/focusing App also retries enrichment. There is no full historical backfill or scan of all users.

PostHog setup after deployment

These are project settings, not settings automatically changed by merging code. First deploy App/Auth and validate a fresh QA, admin and real-user session in the correct project. The property names become discoverable after ingestion. Check a person's profile, not only the Event properties dropdown.

Following PostHog's internal-user filtering guide:

  1. Open Settings → Project → Filter out internal and test users.
  2. Define the inclusion condition as person internal_user is not true, explicitly retaining people for whom the property is not set. If the UI's boolean negative operator does not include unset values, use an OR group: internal_user = false OR internal_user is not set.
  3. Enable the internal/test-user toggle for each relevant existing insight/dashboard. Do not assume changing the default updates every saved insight. Export or note existing settings before bulk changes.
  4. Create a zQA users cohort with person is_zqa = true. Use it for QA-only analysis. Keep admins (is_admin = true) separately inspectable.
  5. Save a QA Replay view filtered by person is_zqa = true or the cohort. A product-analytics toggle is not a universal filter for every Replay or SQL view.
  6. Add environment = production for Production reporting; use development for Dev. Do not remove the Dev domain or rotate tokens for this feature.

Email is available as a person property after identification and can support an additional internal-domain filter. Do not exclude all @zooly.ai users unless that is the team's intended policy. Prefer the explicit role/tag flags for the primary rule.

Turning off the internal/test-user filter includes all people. SQL insights must apply their own predicates. Account-level QA analysis should use target/account dimensions, not mark all visitors as QA people.

Stats behavior

Stats remains Production-only and admin-only. Its QA data control is independent of PostHog settings:

  • Exclude zQA (default): removes QA-tagged journeys, including their earlier anonymous steps.
  • Include all: includes real and QA journeys.
  • Only zQA: shows QA journeys only.

Both summary and recent journeys use the same predicate. QA membership uses stored event tags/flags or current, non-deleted actor/target/owner accounts. Earlier events are connected by user_session_id within the same environment. Historical unlinked anonymous sessions cannot be classified retroactively.

Account search uses the selected QA mode. A real agency with QA roster members remains selectable under Only zQA; agency classification uses active relationships, not the filtered member count. Deleted accounts remain excluded in every mode. Changing QA mode clears the current account selection and discards stale requests.

Admins are also assigned zQA, so Stats' Exclude zQA drops their tagged journeys after the account stamp lands. Person properties represent the latest known identity; event flags represent event-time state, and Stats also checks current account tags. These views need not produce identical historical counts.

Existing accounts and guest validation

The App identity refresh reconciles the signed-in user's own account tag from verified QA roles/email. This makes earlier events linked to that account filterable without rewriting historical event payloads. It never tags another account selected through Acting As, and never infers QA from a display name containing test or qa. Auth login also assigns zQA to every admin, every @zooly.ai address, and Gmail plus-aliases. Legacy accounts not revisited still need a verified identity reconciliation; this is not an automatic database-wide backfill.

guest_id is conditional: it is sent when there is a canonical Auth guest identity or a safe digest of the App's guest cookie. A direct authenticated login without a guest identity need not have it. To validate guest-to-login, start in a fresh anonymous browser, enter the guest flow, then sign in in that same browser. Confirm the guest ID on the resulting person properties and events. Merely opening /api/analytics/identity reads the payload; it does not send an identify event to PostHog.

The native PostHog internal-user filter is an inclusion condition: keep person internal_user != true (including unset properties). Validate with the QA person present when the toggle is off and absent when it is on. Enable the default for new insights; existing saved insights keep their own toggle until explicitly changed. This does not delete events or recordings.

Existing accounts and guest validation

The App identity refresh reconciles the signed-in user's own account tag from verified QA roles/email. This makes earlier events linked to that account filterable without rewriting historical event payloads. It never tags another account selected through Acting As, and never infers QA from a display name containing test or qa. It does not change Auth roles or grant/revoke admin. An ordinary name@zooly.ai administrator remains non-QA unless their account is explicitly tagged. Legacy accounts not revisited still need a verified identity reconciliation; this is not an automatic database-wide backfill.

guest_id is conditional: it is sent when there is a canonical Auth guest identity or a safe digest of the App's guest cookie. A direct authenticated login without a guest identity need not have it. To validate guest-to-login, start in a fresh anonymous browser, enter the guest flow, then sign in in that same browser. Confirm the guest ID on the resulting person properties and events. Merely opening /api/analytics/identity reads the payload; it does not send an identify event to PostHog.

The native PostHog internal-user filter is an inclusion condition: keep person internal_user != true (including unset properties). Validate with the QA person present when the toggle is off and absent when it is on. Enable the default for new insights; existing saved insights keep their own toggle until explicitly changed. This does not delete events or recordings.

Validation and rollout

Run npm ci then npm run test:product-analytics. The suite covers trusted profile allowlisting, guest pseudonyms, browser login/logout races, server retries and real PostgreSQL queries (PGlite) for the three QA modes and agency scopes.

Deploy App before Auth's enriched internal events, then Auth, Stats and Docs. The internal schema is backward-compatible with old Auth senders; old App receivers reject the new analyticsUser field, so rollout order matters. No new environment variable, token, production migration or existing-data deletion is required.

After Dev validation, promote the reviewed change to Stage/Main with target-specific conflict checks. Production end-to-end acceptance must use a new app.zooly.ai session. PR creation, deployment and native PostHog filter activation are separate checkpoints, not proof of one another.

Before declaring complete, verify: one real person; one zQA person; one admin; a real person visiting a QA target; guest-to-signup; email and social signup; modal and Z-Link; logout then a second user; role/tag removal; all three Stats modes; and QA/real Replay filters. Do not claim historical replays or missing identity data have been backfilled.