Skip to Content
Delmare Digital

Signed-in users

Identify users and staff, link visitors to accounts at sign-in, credit conversions correctly, and erase a user's data.

If your site has logins, wire three things: tell the runtimes who is signed in (so staff are excluded and user tests work), link a browser to its account at sign-in (so outcomes recorded against the account reach the right visitor), and erase test data when you erase a user.

Identify users and staff

In the server runtime, add your session lookup and the attributes to use for targeting:

src/experiments/serverRuntime.ts
import { defineExperimentsServerRuntime } from '@delmaredigital/payload-experiments/runtime/server'
import { proxyRuntime } from './proxyRuntime'
import { getSessionUser } from '@/auth/session'

export const serverRuntime = defineExperimentsServerRuntime(proxyRuntime, {
  getPayload: async () => {
    const { getPayload } = await import('payload')
    const { default: config } = await import('@payload-config')
    return getPayload({ config })
  },
  getUser: () => getSessionUser(),
  attributesOf: (user) => ({ loggedIn: Boolean(user), staff: user?.role === 'staff' }),
})
OptionDefaultPurpose
getUsernoneReturns the signed-in user or null
userIdOfString(user.id)The opaque id used as the unit in user tests
attributesOf{ loggedIn: Boolean(user) }Targeting attributes for the user. Server attributes take precedence.

In the proxy runtime, add getUnit, which runs in the proxy and must not call Payload:

src/experiments/proxyRuntime.ts
export const proxyRuntime = defineExperimentsProxyRuntime({
  // ...
  getUnit: async (request) => {
    const token = request.cookies.get('session')?.value
    if (!token) return { attributes: { loggedIn: false, staff: false } }
    const cached = await sessionCache.get(token)
    if (!cached) return { attributes: {}, sessionUnknown: true }
    return { userId: cached.userId, attributes: { loggedIn: true, staff: cached.staff } }
  },
})
  • With no session token, return known logged-out attributes.
  • With a session you can verify cheaply (a cache), return the user id and attributes.
  • With a token you cannot verify in the proxy, set sessionUnknown: true. User tests and rules on loggedIn or staff are then resolved on the server through getUser.

Declare staff: { type: 'boolean' } in the plugin's attributeKeys. If you declare staff, you must pass your own attributesOf that identifies staff; the plugin refuses to start otherwise. Staff produce no exposures.

Use opaque user ids. Keep names, emails and other identifying values out of attributes.

Call linkIdentity on every account-creation path and every sign-in that proves ownership:

src/auth/callbacks.ts
import { linkIdentity } from '@delmaredigital/payload-experiments/rsc'
import { serverRuntime } from '@/experiments/serverRuntime'

// Registration, before the person has proved they own the account:
await linkIdentity(serverRuntime, { headers: request.headers, userId, verified: false })

// After a successful password sign-in, magic-link or code verification, or OAuth callback:
await linkIdentity(serverRuntime, { headers: request.headers, userId, verified: true })

verified is required. Pass true only when this request proves, in this browser, that the person owns the account. A session existing, a session refresh, or the account's email-verified flag is not proof. An auth "after" hook that fires whenever a session cookie is set also fires on routine refreshes, so do not pass true from there.

linkIdentity links the browser's signed visitor id to the user id. It uses its own database connection and never throws into sign-in. It returns:

ResultMeaning
'linked'A new link was created
'verified'An existing unverified link was upgraded
'skipped'No consent, no valid visitor cookie, or a link with nothing to change
'failed'An error, logged safely

The first link per browser wins; a different account never replaces it.

How linked outcomes are credited

In a visitor test, an event recorded against a user (for example by a user-keyed metric source) is credited to one of that user's linked visitors:

  • Verified links are always eligible.
  • Unverified links are eligible only for events before the user's first verified sign-in on any browser, or for all events if the user never verifies.
  • Each event goes to the earliest exposed eligible visitor.
  • An event recorded in a visitor's own browser is always credited to that visitor if they saw the test.

Results report the share of users whose credited events span more than one group as an identity conflict.

Known limits of this rule:

  • Cross-device. Register on device A, verify on device B, keep using A. A's events after B's proof are not credited until a proving sign-in on A. Events before B's proof remain credited, and once A is verified, the next results run credits A's events from the gap too.
  • Password before verification. A later password sign-in counts as proof. Whether an unverified registration may set a password is your authentication's decision.
  • Shared device. A different user's sign-in never relinks a browser that is already linked; that user's events get no credit through that browser.
  • Someone else's email. If a person registers another person's email from their own browser and the owner never signs in, events your app records against that account are credited to the registering browser. It moves one person's value, never assignment, and has no time limit.

Erase a user

When you erase a user, call eraseUser from /rsc in your authorised erasure flow:

import { eraseUser } from '@delmaredigital/payload-experiments/rsc'

const { identities, events } = await eraseUser(payload, { userId })

It deletes the user's identity links and associated events in one transaction and returns the counts.

Edit this page on GitHub

On this page