Session Timeout Is a Rule, Not a User Boundary

A dashboard reports fewer sessions after an ingestion change, even though the number of events stays stable. Before interpreting that as more engagement, check whether the timeout, identity key, event order, or observation window changed. A session count is the result of a definition applied to events, not a direct count of people.

I work on SensorFlow. This AI-assisted tutorial uses synthetic, offline fixtures and a small Node.js implementation; it is not a claim that SensorFlow implements this session model or that a client SDK has been tested end to end.

Start with a testable definition

An inactivity session groups qualifying events for a chosen actor key while consecutive event gaps remain below a specified threshold. The actor key, qualifying events, timestamp semantics, and equality at the threshold are part of the metric contract. Changing any of them can change the result without changing product behavior.

Google Analytics documents a default inactivity timeout of 30 minutes, with configuration options. That is a vendor's documented behavior, not a universal law or a promise that the example below reproduces GA4. The example deliberately defines a new session when the gap is greater than or equal to 30 minutes; an event exactly at the boundary must therefore start a new session in these tests.

Use event occurrence time for this exercise, not arrival order. Delayed delivery can reorder a sequence, and rebuilding sessions after late events arrive can change earlier results. Published reports need an explicit lateness and revision policy rather than silently treating every count as final.

Run the boundary fixture

Save the following code as sessions.cjs and run node sessions.cjs with Node.js. It needs no account, network access, database, SDK, or production payload. The rows are assumed to have been deduplicated and to contain only qualifying events; deciding which events qualify is a separate contract.

const assert = require('node:assert/strict');
const GAP = 30 * 60 * 1000;
function sessionize(rows) {
  const groups = new Map();
  let rejected = 0;
  for (const row of rows) {
    if (typeof row.actor !== 'string' || row.actor.length === 0 ||
        !Number.isSafeInteger(row.t) || row.t < 0) {
      rejected++;
      continue;
    }
    if (!groups.has(row.actor)) groups.set(row.actor, []);
    groups.get(row.actor).push(row.t);
  }
  const sessions = [];
  for (const [actor, times] of groups) {
    times.sort((a, b) => a - b);
    let current;
    for (const t of times) {
      if (!current || t - current.end >= GAP) {
        current = { actor, start: t, end: t, events: 1 };
        sessions.push(current);
      } else {
        current.end = t;
        current.events++;
      }
    }
  }
  return { sessions, rejected };
}
const count = rows => sessionize(rows).sessions.length;
assert.equal(count([{ actor: 'device:a', t: 0 }]), 1);
assert.equal(count([{ actor: 'device:a', t: 0 }, { actor: 'device:a', t: GAP - 1 }]), 1);
assert.equal(count([{ actor: 'device:a', t: 0 }, { actor: 'device:a', t: GAP }]), 2);
assert.equal(count([{ actor: 'device:a', t: 0 }, { actor: 'device:a', t: GAP + 1 }]), 2);
assert.equal(count([{ actor: 'device:a', t: GAP }, { actor: 'device:a', t: 0 }]), 2);
assert.equal(count([{ actor: 'device:a', t: 0 }, { actor: 'device:b', t: 1 }]), 2);
assert.equal(count([{ actor: 'device:a', t: 0 }, { actor: 'account:a', t: 1 }]), 2);
assert.equal(sessionize([{ actor: 'device:a', t: NaN }]).rejected, 1);
assert.equal(sessionize([{ actor: '', t: 0 }]).rejected, 1);
assert.equal(sessionize([{ actor: 'device:a', t: '0' }]).rejected, 1);
const midnight = Date.parse('2026-10-17T00:00:00Z');
assert.equal(count([{ actor: 'device:a', t: midnight - 1000 }, { actor: 'device:a', t: midnight + 1000 }]), 1);
assert.equal(sessionize([{ actor: 'device:a', t: 0 }, { actor: 'device:a', t: 1 }]).sessions[0].events, 2);
console.log('PASS: 12 session boundary assertions');

The expected output is PASS: 12 session boundary assertions. A two-second sequence across UTC midnight stays in one session under this contract. Distinct device and account namespaces remain separate even if their text suffixes match. That separation prevents accidental merges; it does not solve the harder problem of joining anonymous and authenticated activity.

The code uses a numeric comparator because Array.sort sorts as strings by default. It sorts arrays built inside the function rather than mutating the caller's rows. Map supplies the grouping structure, Number.isSafeInteger rejects missing and non-integer timestamps, and Node strict assertions make a changed boundary fail visibly.

Three ways a correct function can still produce a misleading report

First, the input may omit the event immediately before the reporting period. Suppose an event happened at 23:59 and another at 00:01. If a daily job loads only the second event, it may incorrectly label it a new session. Sessionization needs appropriate prior context or persisted boundary state. The fixture's midnight assertion is not proof that an arbitrary daily query loads enough history.

Second, a heartbeat or background event may keep a session alive even though no person is actively using the product. Choose which events advance the inactivity clock. A session is not equivalent to foreground attention, active time, or successful use, and this example calculates none of those quantities.

Third, device keys and account keys answer different questions. A shared device can contain multiple people; one account can use several devices. If login changes the key, record and test that policy instead of retrospectively merging every event with a matching text identifier. Do not use a session ID as proof of identity.

Make the report contract part of release review

  1. Record the actor namespace and the event types allowed to advance the clock.
  2. Specify timestamp units, invalid-time handling, the timeout value, and equality at the threshold.
  3. Test reversed delivery order, exact boundaries, midnight, and namespace changes with synthetic data.
  4. Define prior-period context, late-arrival revisions, and how incomplete sessions are presented.
  5. Compare the same fixtures before and after any SDK or destination migration; investigate definition changes before attributing the difference to growth.

The sample keeps all timestamps in memory, has no persistence, and cannot safely partition a large stream without carrying boundary state. It has no watermark, cross-device identity graph, bot filtering, or distributed state coordination. Those are engineering requirements to evaluate separately, not features implied by twelve passing assertions.

SensorFlow is an open-source, self-hosted event analytics pipeline using the official Sensors Data SDK ecosystem. The open-source deployment and synthetic demo can run without a license; receiving real SDK payloads requires commercial license activation. The session model here is an independent teaching example, not an advertised built-in session report.

For a practical next step, run the fixture against your team's written session definition, then use the SensorFlow event verification guide to distinguish demo data from real ingestion. A defensible session metric begins with a definition that survives edge cases, not with a more precise-looking chart.

评论

此博客中的热门博文

Choosing an Event Analytics Stack Without a Feature Checklist

Event Tracking QA: Why Signup Funnel Numbers Disagree

Event Count Is Not User Count: Define the Metric First