Skip to content

Track session duration in app_metadata

At logout, calculate how long the user's session lasted and persist the duration — plus a cumulative session count and total time — into app_metadata. Your product analytics and reporting tools can read these values directly from the user record without a separate event pipeline.

Any product that wants per-user engagement metrics from the auth layer can use this recipe. It is especially useful early in a product's life, before a dedicated analytics pipeline exists. The values land in app_metadata immediately and are readable from the Users detail page in the admin console or from your application via the user profile API. You get session count, last session duration, and total time across all sessions — enough to compute average session length, identify power users, and flag accounts that have never returned after signup.

Cymmetri uses this pattern to feed a weekly active user report without routing every login through a separate event bus. The per-user totals accumulate in the auth layer and are exported on a nightly schedule.

A custom action named track-logout-stats triggered on Logout. The action reads event.user.last_login_at to determine when the current session started, computes the duration in seconds, updates the running totals in app_metadata, and calls api.user.setAppMetadata to persist them. You will attach this action to the Logout flow.

The complete configuration is:

  1. A custom action named track-logout-stats, trigger Logout.
  2. A Run Custom Action block in the Logout flow, positioned before the terminal Revoke Session block.
  3. The Run Custom Action block configured to run track-logout-stats.

Open Custom Actions in the left navigation and click + New Action.

Set:

  • Name: track-logout-stats
  • Trigger: Logout

Leave the other settings at their defaults and click Save. The code editor opens.

Replace the default function body with the code in the Code section below. Click Save.

Toggle the action to Enabled in the action detail header. Confirm the toggle is on — a disabled action is skipped silently.

Step 4 — Add the block to the Logout flow

Section titled “Step 4 — Add the block to the Logout flow”

Navigate to Flows, click Logout, then click Edit.

In the flow editor, drag a Run Custom Action block from the block palette into the Logout flow. Place it before the Revoke Session block — app_metadata must be written while the session is still active.

In the block's configuration panel, select track-logout-stats from the Action dropdown. Click Save.

Click Publish in the flow editor toolbar. The change is live for the next logout.

async function trackLogoutStats(event, api) {
const now = new Date();
// event.user.last_login_at is the ISO 8601 timestamp of the most recent
// successful login. It is set by the platform at issue_session time.
const lastLoginAt = event.user?.last_login_at;
// Compute session duration in seconds.
// If last_login_at is missing or unparseable, skip the duration write
// but still increment the session count.
let durationSeconds = 0;
if (lastLoginAt) {
const loginTime = new Date(lastLoginAt);
const rawDuration = Math.round((now - loginTime) / 1000);
// Guard against clock skew: a negative duration means the login
// timestamp is in the future, which is a data error. Clamp to 0.
durationSeconds = Math.max(0, rawDuration);
// A duration under 60 seconds most likely means the user logged in
// and immediately out — a page reload or session probe, not a real
// engagement. Skip writing to avoid polluting the totals.
if (durationSeconds < 60) {
api.log('info', `Session too short to record (${durationSeconds}s) — skipping duration write`);
durationSeconds = 0;
}
} else {
api.log('warn', 'last_login_at missing — duration will not be recorded for this session');
}
// Read existing metadata to accumulate totals.
const existing = event.user?.app_metadata ?? {};
const previousCount = typeof existing.session_count === 'number' ? existing.session_count : 0;
const previousTotalSeconds = typeof existing.total_session_seconds === 'number'
? existing.total_session_seconds
: 0;
const newCount = previousCount + 1;
const newTotalSeconds = previousTotalSeconds + durationSeconds;
// Write the updated totals back.
// setAppMetadata is a shallow merge: only the keys listed here are updated.
// All other existing app_metadata keys are preserved.
api.user.setAppMetadata({
last_logout_at: now.toISOString(),
last_session_duration_sec: durationSeconds,
session_count: newCount,
total_session_seconds: newTotalSeconds,
});
api.log('info', `Logout recorded — duration: ${durationSeconds}s, total sessions: ${newCount}, total time: ${newTotalSeconds}s`);
}

Open the action in Custom Actions, click Test, and select the Logout preset.

In the Event JSON editor, locate user.last_login_at and set it to a timestamp two hours before the current UTC time, for example "2026-05-20T08:00:00.000Z" if the current time is 10:00:00 UTC. Set user.app_metadata to { "session_count": 3, "total_session_seconds": 9000 } to simulate existing totals. Click Run.

In the API calls tab of the result, you should see:

  • api.user.setAppMetadata called with last_session_duration_sec around 7200, session_count equal to 4, and total_session_seconds around 16200.
  • api.log called with the summary line.

Now remove last_login_at from the Event JSON and run again. You should see api.log called with the missing-timestamp warning and api.user.setAppMetadata called with last_session_duration_sec: 0 — confirming the guard path works.

Sign in to your tenant as a test user. Wait at least a minute, then sign out through the normal sign-out flow. Navigate to Users in the admin console, open the test user's detail page, and scroll to the App metadata section. You should see last_logout_at, last_session_duration_sec, session_count, and total_session_seconds written there with values consistent with the time you waited.

Sign in and out a second time. Confirm session_count increments and total_session_seconds grows by the second session's duration.

Tab-close does not trigger logout. This recipe measures "time between login and explicit logout." If a user closes the browser tab or lets the session expire without clicking sign out, no Logout flow fires and no duration is recorded for that session. The session count increments on logout, not on login, so an abandoned session is simply not counted. For products that need to capture all session endings — including browser close — a separate server-side session expiry event or a heartbeat approach is required.

app_metadata writes are persistent and immediate. Unlike api.session.setCustomClaim, which only affects the current access token, api.user.setAppMetadata writes to the user record in the platform's identity store. The values persist across sessions, devices, and tenants. Test against a scratch user identity first. If you need to reset the counters on a real user for testing, edit the app_metadata directly from the Users detail page.

Clock skew can produce zero-duration sessions. The duration computation relies on event.user.last_login_at being accurate. If the platform clock and your testing machine's clock diverge significantly, you may see negative raw durations. The Math.max(0, rawDuration) guard in the code clamps these to zero, so the count still increments but the duration contribution is discarded. In production this is rare; in local development against a cluster with time-sync issues it may appear more often.

Shallow merge semantics. api.user.setAppMetadata merges at the top level only. If app_metadata.tier was set by another recipe, this action does not overwrite it. However, if you nest objects inside app_metadata and pass those nested objects in the patch, the entire nested object is replaced, not merged. Keep the fields written by this action flat.