Track a Workflow Run, Not Just a User

Two browser tabs, one account, two unfinished workflows. A user-level report can accidentally combine the start in one tab with the completion in the other. Workflow event tracking needs a unit smaller than a person: one explicitly identified run of the business process.

This tutorial uses synthetic events and a small, runnable Node.js validator. It tests grouping, duplicate delivery, conflicting identifiers, and missing stages. It does not connect to an SDK, database, or production service, and the example numbers are not SensorFlow performance or customer results.

What should identify a workflow run?

A workflow run is one attempt to carry a particular business process from its defined start toward its defined outcome. Attach the same stable workflow_run_id to its stages. Scope it by project, preserve it across delivery retries, and use a different identifier for a genuinely new run. A user identifier answers who; a run identifier answers which process.

For example, one account might submit two applications. The first application finishes; the second is still pending when the report is taken. “Accounts with any completion” and “completed application runs” are both useful, but they have different denominators. Neither should silently substitute for the other.

If you already distinguish event counts from user counts, the next question is whether multiple events belong to the same business attempt. This is also separate from anonymous-to-login identity reconciliation: this example starts with known accounts and does not merge identities.

Define identifiers before sending events

Use four explicit fields, each with one job:

  • project: the namespace in which identifiers are meaningful.
  • account_id: the known account performing this run; fixed within this tutorial's run contract.
  • workflow_run_id: created once at the business start and reused for every stage of that run.
  • event_id: identifies one logical event; retransmitting that event keeps this ID.

The browser's crypto.randomUUID() can generate a UUID in a secure context. Calling it separately for every stage will not correlate a run. Persisting and propagating the selected ID is an application responsibility. In a multi-device process, a server-assigned business identifier may be more suitable; do not assume two clients can independently create the same run ID.

The validator below intentionally models only three required stages: started, submitted, and completed. seq is a synthetic, per-run ordering number, not a wall-clock timestamp. In a real integration, decide where ordering comes from and how late arrivals are handled before using it as evidence of a valid sequence.

Run the synthetic acceptance test

Save the following as workflow-runs.cjs and run node workflow-runs.cjs with Node.js 20 or newer. It uses only built-in modules and makes no network requests.

const assert = require('node:assert/strict');
const stages = ['started', 'submitted', 'completed'];
const key = (...parts) => JSON.stringify(parts);
function summarize(events) {
  const deliveries = new Map();
  const runs = new Map();
  for (const e of events) {
    for (const field of ['project', 'account_id', 'workflow_run_id', 'event_id']) {
      assert.equal(typeof e[field], 'string');
      assert.ok(e[field].trim());
    }
    assert.ok(stages.includes(e.step));
    assert.ok(Number.isSafeInteger(e.seq) && e.seq > 0);
    const signature = key(e.project, e.account_id, e.workflow_run_id, e.step, e.seq);
    const deliveryKey = key(e.project, e.event_id);
    if (deliveries.has(deliveryKey)) {
      assert.equal(deliveries.get(deliveryKey), signature, 'conflicting event_id');
      continue;
    }
    deliveries.set(deliveryKey, signature);
    const runKey = key(e.project, e.workflow_run_id);
    if (!runs.has(runKey)) runs.set(runKey, { account: key(e.project, e.account_id), events: [] });
    const run = runs.get(runKey);
    assert.equal(run.account, key(e.project, e.account_id), 'account changed within run');
    run.events.push(e);
  }
  let completed = 0;
  const accounts = new Set();
  const completedAccounts = new Set();
  for (const run of runs.values()) {
    const ordered = [...run.events].sort((a, b) => a.seq - b.seq);
    assert.ok(ordered.length <= stages.length, 'too many stages');
    ordered.forEach((e, i) => {
      assert.equal(e.step, stages[i], 'missing, repeated, or out-of-order stage');
      if (i) assert.ok(e.seq > ordered[i - 1].seq, 'ambiguous sequence');
    });
    accounts.add(run.account);
    if (ordered.length === stages.length) {
      completed++;
      completedAccounts.add(run.account);
    }
  }
  return { runs: runs.size, completed, pending: runs.size - completed,
    accounts: accounts.size, completedAccounts: completedAccounts.size,
    logicalEvents: deliveries.size };
}
const make = (run, account, step, seq) => ({ project: 'demo',
  workflow_run_id: run, account_id: account, step, seq, event_id: `${run}-${step}` });
const events = [
  make('r1', 'a', 'started', 1), make('r1', 'a', 'submitted', 2), make('r1', 'a', 'completed', 3),
  make('r2', 'a', 'started', 4), make('r2', 'a', 'submitted', 5),
  make('r3', 'b', 'started', 6), make('r3', 'b', 'submitted', 7), make('r3', 'b', 'completed', 8)
];
let checks = 0;
const check = fn => { fn(); checks++; };
const expected = { runs: 3, completed: 2, pending: 1, accounts: 2, completedAccounts: 2, logicalEvents: 8 };
check(() => assert.deepEqual(summarize(events), expected));
check(() => assert.deepEqual(summarize([...events].reverse()), expected));
check(() => assert.deepEqual(summarize([...events, { ...events[0] }]), expected));
check(() => assert.throws(() => summarize([...events, { ...events[0], seq: 99 }])));
check(() => assert.throws(() => summarize([{ ...events[0], account_id: '' }])));
check(() => assert.throws(() => summarize([events[2]])));
check(() => assert.throws(() => summarize([events[0], events[2]])));
check(() => assert.throws(() => summarize([events[0], { ...events[1], account_id: 'other' }])));
check(() => assert.throws(() => summarize([events[0], { ...events[0], event_id: 'another' }])));
check(() => assert.throws(() => summarize([events[0], { ...events[1], seq: 1 }])));
check(() => assert.deepEqual(summarize([]), { runs: 0, completed: 0, pending: 0,
  accounts: 0, completedAccounts: 0, logicalEvents: 0 }));
console.log(JSON.stringify(expected));
console.log(`PASS: ${checks} synthetic checks`);

Expected output:

{"runs":3,"completed":2,"pending":1,"accounts":2,"completedAccounts":2,"logicalEvents":8}
PASS: 11 synthetic checks

Why those checks matter

The sample has three runs and two completed runs, but both accounts have at least one completion. A report grouped only by account would hide the unfinished second run for account a. Do not label the pending run as churn, abandonment, or failure: the snapshot contains no evidence for those outcomes and defines no timeout.

Map stores one entry per key, and Set stores distinct values. Those language features do not define business identity. The code scopes the run key to project and uses a separate project/event key to detect delivery duplicates.

The signature includes the known contract fields in a fixed array. JSON.stringify() is not a general-purpose canonicalizer for arbitrary event payloads. This example validates strings and integers first and ignores no business field that it subsequently counts. Extend the signature and validation together if the contract gains meaningful fields.

The eleven top-level checks use Node's strict assertions; validation assertions inside the function are additional guards, not extra advertised test cases. The validator rejects an orphan completion, a missing middle stage, an account change, conflicting reuse of an event ID, and ambiguous sequence numbers. It accepts input arriving in a different array order because the example explicitly supplies an ordering field.

What this does not prove

An in-memory duplicate check is not durable idempotency or exactly-once delivery. The process forgets its maps when it exits. It also does not verify whether an application really completed, repair missing SDK events, or undo side effects. Before deploying, define retention for identifiers, late-event windows, cancellation and retry states, and the authoritative source of completion.

For a long-running workflow, you might report started, completed, failed, canceled, and still open at a defined cutoff. That is a broader state machine than this deliberately small three-stage prefix contract. Do not just append new labels without changing the transition tests. Likewise, a session timeout is a reporting rule, not proof that a business run ended.

Where SensorFlow fits

SensorFlow is an open-source, self-hosted event analytics pipeline using the official Sensors Data SDK ecosystem. Application teams still need to attach and propagate their business identifiers; SensorFlow does not automatically infer workflow boundaries. Its open deployment and synthetic demo are available without a license, while receiving real SDK payloads requires an activated commercial license. This tutorial is a local contract test, not an SDK compatibility or throughput benchmark.

For the maintained setup and activation steps, use the SensorFlow developer documentation. Start by checking whether one deliberately unfinished run stays unfinished in your report, even when the same account completes another.

Disclosure: written for the SensorFlow project by its maintainer's publishing workflow, with AI-assisted drafting and executable synthetic checks. No real user events are included.

评论

此博客中的热门博文

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