Analytics and attribution tracking for the Qbix platform. Metrics is self-contained — it has no dependencies on Users, Streams, or any other plugin — but exposes hooks and conventions that other plugins can use to layer on user identity, access control, and referral rewards.
Metrics tracks four things: trackers (attribution sources), visits (browsing sessions), hits (individual page views within a visit), and conversions (aggregated outcomes sliced by traits).
A tracker is a named campaign or link source. It answers "where did traffic come from?" A tracker might represent a specific email blast, a billboard QR code, a social media post, or a referral link. Trackers carry sentCount (how many people you sent it to) and visitsCount (how many actually showed up).
A visit is one browsing session tied to a tracker. When someone arrives at your site via a tracked link, a visit is created, stored in the PHP session, and reused for the duration of that session. Each visit records platform, form factor, IP, and geo data.
A hit is a single page view within a visit. Every web request fires the Q/metrics after-handler, which records the URL as a hit. Hits carry the actionId (URL path), fromActionId (previous page), trackerId, and optional finalState and extra fields that can be updated later by client-side code.
An action is a canonical URL identity. Long URLs are hashed to fit the 63-byte key limit. Actions have a canonicalActionId so that multiple URL variants (with different query strings) can map to the same logical action.
Traits are named attribute/value pairs attached to trackers via a join table. A trait might be name=medium, content=email or name=utm_campaign, content=summer2026. The trait system lets you slice conversion data across arbitrary dimensions without adding columns to the tracker table.
Conversions aggregate how many visits from trackers with a given trait hit a given action, plus average time-to-conversion measured both from tracker creation and from visit start.
Primary attribution source. The id field is free-form (varbinary(63)) and can follow any convention — the comment suggests publisherId/someName.
| Column | Type | Purpose |
|---|---|---|
id |
varbinary(63) | PK. Can be publisherId/someName or any string. |
publisherId |
varbinary(31) | The user gathering metrics. |
sentCount |
int | Estimated number of people this tracker was sent to. |
visitsCount |
int | Total visits from this tracker. |
insertedTime |
timestamp | Row creation time. |
updatedTime |
timestamp | Last update time. |
One browsing session, tied to a tracker.
| Column | Type | Purpose |
|---|---|---|
id |
varbinary(63) | PK. Auto-generated with v- prefix. |
trackerId |
varbinary(63) | The tracker that brought this visitor. May also be visitId:parentVisitId for visit chaining. |
startTime |
timestamp | When the visit began. |
lastTime |
timestamp | Last activity time. |
lastActionId |
varbinary(63) | The most recent action hit in this visit. |
platform |
varbinary(31) | OS/browser platform. |
formFactor |
enum | desktop, mobile, tablet, other. |
IP |
varbinary(255) | IPv4 or IPv6. |
countryCode |
varchar(2) | Two-letter country code (from IP geolocation). |
postcode |
varchar(20) | Postal code (from IP geolocation). |
geonameId |
int | GeoNames ID (from IP geolocation). |
Individual page view within a visit.
| Column | Type | Purpose |
|---|---|---|
visitId |
varbinary(31) | Which visit this hit belongs to. |
actionId |
varbinary(63) | The action (URL path) that was visited. |
fromActionId |
varchar(63) | The previous action, if any. |
trackerId |
varbinary(63) | The tracker, copied from the visit for denormalized querying. |
insertedTime |
timestamp | When the hit occurred. |
finalState |
varchar(63) | Updated later by client code — e.g. completed, abandoned, error. |
extra |
varchar(1023) | JSON payload for error details, metadata, etc. |
Canonical URL identity.
| Column | Type | Purpose |
|---|---|---|
id |
varbinary(63) | PK. Derived from the URL path; long paths are truncated + SHA1-hashed. |
url |
varbinary(2083) | The original URL. |
canonicalActionId |
varbinary(63) | Maps URL variants to one logical action. Derived from the Qbix internal URI. |
Named attribute values for slicing analytics.
| Column | Type | Purpose |
|---|---|---|
id |
varbinary(63) | PK. Synthetic key. |
name |
varchar(63) | Trait name — e.g. platform, medium, utm_campaign. |
content |
varbinary(63) | Trait value — e.g. email, facebook, summer2026. |
sentTotal |
int | Total sentCount across all trackers with this trait. |
visitsTotal |
int | Total visits from trackers with this trait. |
Join table linking trackers to their traits.
| Column | Type | Purpose |
|---|---|---|
trackerId |
varbinary(63) | FK to tracker. |
traitId |
varbinary(63) | FK to trait. |
Aggregated conversion stats per trait/action pair.
| Column | Type | Purpose |
|---|---|---|
traitId |
varbinary(63) | Which trait. |
actionId |
varbinary(63) | Which action was hit. |
visitsTotal |
int | How many visits from trackers with this trait hit this action. |
sinceTracker |
decimal(10,4) | Average seconds from tracker creation to this hit. |
sinceVisit |
decimal(10,4) | Average seconds from visit start to this hit. |
Every web request triggers Metrics_after_Q_metrics (registered in plugin.json as an after-handler for Q/metrics):
function Metrics_after_Q_metrics()
{
// reads trackerId from configured querystring fields
// default config: look for "sourceId" in $_GET or the URI
$trackerId = /* resolved from $_GET or URI */;
$url = Q_Request::url();
Metrics_Hit::registerUrl($url, $trackerId);
}Metrics_Hit::registerUrl resolves the URL to a Metrics_Action (creating one if needed), then calls Metrics_Hit::register($actionId, $trackerId), which:
- Calls
Metrics_Visit::current($trackerId)— resumes from session or creates a new visit. - Inserts a hit row with
{visitId, actionId, fromActionId, trackerId}. - Updates the visit's
lastActionIdandlastTime.
The Metrics/landed POST handler lets client code explicitly associate a visit with a tracker after page load:
function Metrics_landed_post()
{
$trackerId = $_POST['trackerId'];
$visit = Metrics_Visit::current($trackerId);
if (empty($visit->trackerId)) {
$visit->trackerId = $trackerId;
$visit->save();
Q::event('Metrics/landed/visit/started', ['visit' => $visit]);
}
Q_Response::setSlot('visitId', $visit->id);
}This is used for visit chaining (see below) and for cases where the tracker ID isn't in the querystring.
The Metrics/update POST handler lets client code update the finalState and extra on a recent hit:
// finds the matching hit among the last 5 for this visit
// updates finalState and/or extraThis powers conversion tracking (marking a hit as completed, abandoned, step2, etc.) and error telemetry.
If the Users plugin is loaded, the Metrics/before/Users_User_lastActiveTime handler derives user activity from visit data:
function Metrics_before_Users_User_lastActiveTime($params, &$result)
{
// queries visits by userId prefix in the visit ID
// returns the lastTime of the most recent visit
}This hook only fires if Users is present — Metrics doesn't require it.
Metrics.setState(state, extra)Debounced (5 seconds) POST to Metrics/update. Sets finalState and extra on the current hit. Use this to mark funnel steps, completion, abandonment, or errors.
Metrics.js installs global handlers for window.error and unhandledrejection. Caught errors are POSTed to Metrics/update with state: 'error' and a structured payload containing message, stack, URL, user agent, and timestamps.
For cross-page or cross-origin attribution, Metrics.js propagates visit IDs via the URL hash fragment #v=visitId:
- On page load, if
#v=parentVisitIdis present, POST toMetrics/landedwithtrackerId: 'visitId:' + parentVisitId. - The server stores this verbatim as the visit's
trackerId. ensureVisitInHashuseshistory.replaceStateto maintain the#v=parameter across client-side navigation.- The response's
visitIdis written back into the hash for downstream pages.
The "visitId:" prefix is a convention, not a parsed protocol. The server stores it as-is. To resolve the chain, use Metrics_Visit::referringVisitId():
/**
* Gets ID of the referring visit, if any, otherwise ""
* @return {string}
*/
function referringVisitId() {
$trackerId = $this->trackerId;
if (Q::startsWith($trackerId, 'visitId:')) {
return substr($trackerId, 8);
}
return "";
}Walk the chain by following referringVisitId() until you reach a visit whose trackerId is a real tracker (not a visitId: reference). That root tracker is the original attribution source.
In plugin.json:
{
"Metrics": {
"action": {
"id": {
"length": 30
}
},
"querystring": {
"fields": {
"trackerId": ["sourceId"]
}
},
"visit": {
"enrich": {
"location": true
}
}
}
}Metrics.querystring.fields.trackerId is an array of field names to check in $_GET (and the Qbix URI) for the tracker ID. Default is ["sourceId"], so visitors arrive via ?sourceId=myTracker.
Metrics.action.id.length controls how many characters of the SHA1 hash are used when a URL path exceeds 63 bytes.
Metrics trackers and Streams invite tokens serve different purposes — trackers handle attribution, invite tokens handle access grants — but they can work together for tracked invite links.
The tracker's id (e.g. publisherId/poker-night) becomes the friendly slug in a short URL. The tracker stores or maps to a Streams invite token. On click:
- The link shortener's resolution handler records the Metrics visit and hit.
- Redirects to the app URL with
?Streams.token=inviteToken&sourceId=trackerId. - The Streams plugin's
Streams_before_Q_objectsfollows the invite token (session storage, auto-login, state checking). Metrics_after_Q_metricsfires on the app page and records a second hit under the same tracker.- On invite acceptance,
Streams_Invite::accept()callsUsers_Referred::handleReferral(), creating the referral chain.
Multiple trackers can point at the same invite (e.g. publisherId/poker-email and publisherId/poker-sms), which is the whole point of the trait/conversion schema — same destination, different attribution paths answering "which channel converts better?"
The Groups mobile app rewrites URLs in outbound messages (SMS, email) so clicks can be tracked without exposing contact PII to the server. The architecture uses opaque tokens as the only identifier the server ever sees.
Token generation is client-side only. Groups.Contacts.Tokens.add(contactId) creates a random opaque token for each contact. The mapping token → contactId is stored exclusively in localStorage['Groups_contacts_tokens'] on the device. The server never receives contact names, phone numbers, email addresses, or contactIds.
The three-step pipeline:
-
transformBody(body, contactIds, fields, fill)— regex-extracts all URLs from the message body. For each URL × contactId pair, generates a token. CallssuggestLinks(). -
suggestLinks(payload)— PUT tomanageLinksUrlwith{urls, titles, tokens: tokensByUrl, fields}. ThetokensByUrlstructure is{ "https://original.url": ["tok_a", "tok_b", "tok_c"] }— an array of opaque tokens in contact order. The server sees tokens and URLs but has no way to resolve tokens to people. The server returns{urls: [rewritten...], hmacs: [...]}— each original URL replaced with a trackable short URL. The server can create Metrics trackers at this point, attaching traits fromfields. -
sentLinks({urls, hmacs})— POST tomanageLinksUrlafter the message is actually sent. Confirms delivery. Only sends URLs and HMACs (for tamper verification). No contact info.
When the server sends instructions back (via push notifications or the update response), it references contacts by token. The app resolves them locally:
const token = contact.encryptedToken || contact.token;
const contactId = Groups.Contacts.byToken?.[token];
if (!contactId) continue; // unknown token → skipThe server can say "update the contact with token X" but never "update John Smith" — it doesn't know John Smith exists. This is the core privacy invariant: the server operates on opaque tokens, the device holds the token-to-identity mapping.
The token map survives app reinstalls via the backup/snapshot system (saveSnapshot / loadAndMergeSnapshot include Groups_contacts_tokens).
Server-side sending via processByServer(fields, identifiers, contactIds) is a separate path for when the server handles SMS/email dispatch directly. In that case the server does receive identifiers (phone numbers, emails) because it needs them to send the messages. This is an explicit trust boundary — the user chooses server-side sending knowing the server sees the recipients.
Three token types serve distinct roles:
| Token | Lives in | Purpose |
|---|---|---|
trackerId |
Metrics tracker table, visit, hit | Attribution — which campaign/link/channel |
inviteToken |
Streams invite table | Access control — grants participation in a stream |
contactToken |
Device only (localStorage) |
Recipient identity — which contact clicked. Server sees the token but cannot resolve it to a person. |
Metrics visit chaining (visitId:parentVisitId in the trackerId field) is the anonymous equivalent of Users_Referred. Both track "who/what brought this person here and deserves credit."
| Users_Referred | Metrics visit chain |
|---|---|
userId |
child visitId |
referredByUserId |
parent visitId (via referringVisitId()) |
toCommunityId |
actionId on the hit |
points / qualifiedTime |
conversion table aggregates |
handleReferral() resolves chain, awards points |
referringVisitId() resolves one link; walk to root for full chain |
The difference is that Users_Referred requires user identity (login), while Metrics visit chaining works for anonymous visitors. When a visitor later logs in and accepts an invite, the Metrics visit chain and the Users referral chain can be joined — the visit's trackerId identifies which tracker (and therefore which referrer) brought them in.