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.
Recommended event fields#
{
"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:
- read the current session record from permitted first-party storage
- compare now with its last meaningful activity
- create a new ID if no session exists or the timeout elapsed
- update activity only for approved events
- 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.