DocsGuides

Guides

Sessionization and engaged-time measurement

6 min readReviewed July 2026

A session is an analytical grouping rule, not a fact GraphJSON invents automatically. Two teams can receive the same events and report different session counts because they chose different inactivity, campaign, background, or identity policies.

Define the policy before building session metrics:

grain: one browser or app visit
timeout: 30 minutes of inactivity
cross-tab: shared within one first-party origin
background: mobile session ends after 5 minutes backgrounded
campaign change: does not start a new session
authentication: anonymous session continues after sign-in
version: 2

Attach a stable session_id at collection time when possible. Query-time sessionization is useful for exploration and historical repair, but it is more expensive and can change as late events arrive.

Know which unit you are measuring#

Unit Example question
Event How many pages were viewed?
Session How many visits reached activation?
User How many people were active?
Account How many customers received value?
Engaged interval How long was the product plausibly in active use?

Do not use sessions as a synonym for users. One user can have several sessions, and anonymous activity may not map to a known person at all.

{
  "event": "report_viewed",
  "event_id": "evt_01J...",
  "session_id": "ses_01J...",
  "session_version": 2,
  "anonymous_id": "anon_01J...",
  "user_id": "usr_42",
  "account_id": "acct_7",
  "surface": "web",
  "page": "report",
  "visibility_state": "visible",
  "schema_version": 1
}

Generate opaque random identifiers. Never derive a session ID from an IP address, email address, user-agent string, or browser fingerprint.

Keep the session_version. Without it, a timeout-policy change silently mixes two definitions in one historical chart.

Create browser sessions#

A typical browser implementation:

  1. read the current session record from permitted first-party storage
  2. compare now with its last meaningful activity
  3. create a new ID if no session exists or the timeout elapsed
  4. update activity only for approved events
  5. attach the ID and version to each event

Store:

type BrowserSession = {
  id: string;
  version: number;
  startedAt: number;
  lastActivityAt: number;
};

Share the session across tabs with localStorage or another same-origin coordination mechanism only when that matches the definition. Use storage events or a BroadcastChannel to reduce two tabs creating new sessions simultaneously.

Do not let every mouse movement extend the session. Approved activity might include navigation, a meaningful interaction, or a bounded engagement heartbeat while the document is visible.

Apply the consent and first-party collection controls in Browser and mobile collection.

Define mobile background behavior#

Mobile operating systems can suspend an app without warning. Decide whether:

  • a short background interval continues the same session
  • the session ends immediately on background
  • the normal inactivity timeout applies

Record lifecycle events only when they answer a real question:

{
  "event": "app_foregrounded",
  "session_id": "ses_01J...",
  "background_duration_ms": 92000,
  "surface": "ios"
}

Do not depend on a guaranteed session_ended event. A process can be killed, the device can lose power, and a browser can close without completing a request. Derive the end as the last accepted activity plus your documented interpretation.

For offline delivery, preserve occurrence time and the original session ID. Arrival time must not create a new visit.

Continue or split at authentication#

When an anonymous visitor signs in, a common product-analytics policy is:

keep the same session_id
emit identity_linked
attach user_id to subsequent events

This preserves the visit while making the identity transition explicit. Do not rewrite anonymous historical events in place.

For a shared device, logout should usually end the authenticated identity context. Whether it also ends the session depends on the stated policy.

When a person switches accounts inside one session, keep both user_id and the active account_id on each event. Never infer account scope from the user’s current membership later.

See Identity stitching and account lifecycle for merge and account-switching rules.

Measure engaged time with bounded heartbeats#

Session duration is usually:

last event time - first event time

That does not prove continuous attention. A tab opened at 09:00 and closed at 17:00 after two events is not necessarily eight hours of engagement.

For products where active time matters, accumulate bounded intervals while:

  • the document or app is visible
  • the user has interacted recently
  • the device is not idle according to an approved policy
  • consent permits measurement

Send a heartbeat at a restrained interval:

{
  "event": "engagement_interval_recorded",
  "event_id": "eng:ses_01J:0007",
  "session_id": "ses_01J...",
  "surface": "web",
  "interval_start": 1785081600,
  "interval_end": 1785081630,
  "engaged_seconds": 30,
  "heartbeat_sequence": 7,
  "session_version": 2
}

Cap engaged_seconds at the heartbeat interval. Do not report the entire gap after a suspended process resumes. Stable sequence-based IDs make uncertain retries deduplicatable.

Heartbeat volume can dominate event cost. Use the longest interval that still supports the decision, and skip this instrumentation when meaningful product actions are a better engagement signal.

Calculate session bounds#

When events already contain session_id:

WITH sessions AS (
  SELECT
    JSONExtractString(json, 'session_id') AS session_id,
    min(timestamp) AS started_at,
    max(timestamp) AS last_activity_at,
    count() AS event_count,
    uniqExact(JSONExtractString(json, 'event')) AS event_types
  FROM product_events
  WHERE
    JSONExtractString(json, 'session_id') != ''
    AND timestamp >= toUnixTimestamp(now() - INTERVAL 30 DAY)
  GROUP BY session_id
)
SELECT
  count() AS sessions,
  avg(last_activity_at - started_at) AS average_duration_seconds,
  quantile(0.5)(last_activity_at - started_at) AS median_duration_seconds,
  avg(event_count) AS average_events_per_session
FROM sessions

Report the median with the mean. Long idle gaps can heavily skew an average. For single-event sessions, duration is zero—not unknown—under this definition.

Sum engaged intervals safely#

Deduplicate heartbeat IDs before summing:

WITH intervals AS (
  SELECT
    JSONExtractString(json, 'event_id') AS event_id,
    argMax(
      JSONExtractInt(json, 'engaged_seconds'),
      timestamp
    ) AS engaged_seconds,
    argMax(
      JSONExtractString(json, 'session_id'),
      timestamp
    ) AS session_id
  FROM product_events
  WHERE
    JSONExtractString(json, 'event') = 'engagement_interval_recorded'
    AND timestamp >= toUnixTimestamp(now() - INTERVAL 30 DAY)
  GROUP BY event_id
)
SELECT
  session_id,
  sum(least(engaged_seconds, 30)) AS engaged_seconds
FROM intervals
GROUP BY session_id

The cap must match the producer contract. Investigate values above it rather than silently relying on the query forever.

Reconstruct sessions at query time#

If historical events lack session IDs, partition by a permitted stable actor key, order by event time plus deterministic event ID, and begin a new group after the inactivity timeout.

Before doing this, decide:

  • anonymous and known identity behavior
  • events that count as activity
  • tie-breaking for events in the same second
  • maximum historical lateness
  • whether midnight, campaign, or application restart splits a session

Persist the SQL definition in source control. A query-time session can move when late events fill a gap, so do not treat recent results as final.

For large repeated analyses, calculate a versioned session assignment in an external job and emit a compact result using Derived events and scheduled rollups.

Define bounce and engagement explicitly#

Possible bounce definitions:

exactly one page view
no meaningful action
less than 10 engaged seconds
no second page or conversion

These produce different results. Name the condition instead of assuming “bounce rate” is universal.

Likewise, prefer decision-specific metrics:

  • sessions reaching first value
  • sessions with a successful core workflow
  • engaged seconds before conversion
  • sessions abandoned after an error

An arbitrary long session is not automatically a good session.

Handle bots, automation, and server activity#

Browser sessions can include crawlers and synthetic monitors. Apply an explicit classification and retain the reason:

{
  "traffic_class": "suspected_bot",
  "classification_version": 3
}

Do not fingerprint people to improve bot classification.

Server-to-server calls and background jobs usually need a request_id, job_id, or workflow_id, not a user session. A support agent acting on behalf of a customer should retain both the actor and subject rather than borrowing the customer’s session.

Validate the policy#

Create deterministic scenarios:

Scenario Expected result
Two events 29 minutes apart One session
Two events 31 minutes apart Two sessions
Two tabs opened simultaneously One session if cross-tab is shared
Anonymous user signs in One session plus identity link
Mobile backgrounds briefly Result follows the background policy
Offline event arrives tomorrow Original session and occurrence time
One heartbeat is retried Engaged time counted once
App is killed No required terminal event

Check session counts, duration, conversion, and engaged time across a complete reporting period before publishing the metric.

Launch checklist#

  • The session grain, timeout, and version are documented.
  • Cross-tab, cross-domain, background, and campaign behavior are explicit.
  • Session IDs are opaque and generated without fingerprinting.
  • Authentication and account switching preserve event-time identity.
  • Only meaningful activity extends the session.
  • Heartbeats are visible-only, bounded, and cost-reviewed.
  • Offline events retain occurrence time and source session.
  • Queries use deterministic event ordering.
  • Bounce and engaged-session definitions are named.
  • Test fixtures cover exact timeout boundaries and retries.
Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support