DocsGuides

Guides

Web analytics and acquisition attribution

5 min readReviewed July 2026

GraphJSON accepts browser-originated events through a first-party endpoint you control. It does not automatically create sessions, classify traffic, persist campaign values, merge identities, filter bots, or choose an attribution model.

That flexibility is useful only when the definitions are explicit.

Define the questions first#

Common web questions use different grains:

Question Grain
How many pages were viewed? Event
How many visits occurred? Session
How many distinct people visited? User or anonymous ID
Which source began this visit? Session acquisition
Which source first introduced this person? First-touch acquisition
Which source preceded a conversion? Conversion attribution

Do not label all of them “traffic by source.” A first-touch chart and a last non-direct conversion chart can disagree while both are correct.

Page view:

{
  "event": "page_viewed",
  "event_id": "evt_01J...",
  "anonymous_id": "anon_01J...",
  "user_id": null,
  "session_id": "ses_01J...",
  "path": "/pricing",
  "path_template": "/pricing",
  "referrer_host": "example.com",
  "session_source": "newsletter",
  "session_medium": "email",
  "session_campaign": "summer_launch",
  "first_source": "google",
  "first_medium": "organic",
  "first_campaign": null,
  "device_type": "desktop",
  "consent_state": "analytics_allowed",
  "schema_version": 1
}

Conversion:

{
  "event": "signup_completed",
  "event_id": "evt_01K...",
  "anonymous_id": "anon_01J...",
  "user_id": "usr_42",
  "account_id": "acct_7",
  "session_id": "ses_01J...",
  "session_source": "newsletter",
  "session_medium": "email",
  "session_campaign": "summer_launch",
  "first_source": "google",
  "first_medium": "organic",
  "first_campaign": null,
  "schema_version": 1
}

Send both anonymous and authenticated identifiers at the transition where policy permits connecting the histories. GraphJSON does not merge them automatically.

Collect through a first-party endpoint#

browser
  → your /api/analytics endpoint
      ├─ apply consent
      ├─ rate-limit
      ├─ allowlist fields
      ├─ normalize path and campaign values
      ├─ derive trusted user/account IDs
      └─ add GraphJSON API key
          → GraphJSON

Follow Collect browser and mobile events safely before implementing campaign logic. Attribution does not justify exposing the workspace key or collecting unsafe URLs.

Parse only allowed campaign parameters#

Common manual campaign parameters:

utm_source
utm_medium
utm_campaign
utm_id
utm_term
utm_content

Do not store an entire query string. It can contain email addresses, reset tokens, search text, or customer identifiers.

const allowed = [
  "utm_source",
  "utm_medium",
  "utm_campaign",
  "utm_id",
  "utm_term",
  "utm_content"
];

function campaignFromUrl(url: URL) {
  return Object.fromEntries(
    allowed
      .map((key) => [key, url.searchParams.get(key)])
      .filter(([, value]) => value)
      .map(([key, value]) => [
        key,
        String(value).trim().toLowerCase().slice(0, 200)
      ])
  );
}

Define source and medium conventions. Newsletter, newsletter, and email-newsletter become three values unless you normalize them.

Handle referrers safely#

Store a hostname, not a complete referrer URL:

function referrerHost(value: string) {
  try {
    return new URL(value).hostname.toLowerCase().slice(0, 200);
  } catch {
    return null;
  }
}

Classify:

explicit UTM values → use campaign source and medium
external referrer   → source = referrer host, medium = referral
no usable source    → source = direct, medium = none

Maintain an internal-domain allowlist so navigation between your own domains does not create a new external referral.

Define a session#

GraphJSON does not create one automatically. A typical policy is:

begin a new session when:
- no session exists
- inactivity exceeds 30 minutes
- campaign changes and the organization chooses to reset
- the app’s foreground/background policy requires it

Record the definition:

session timeout: 30 minutes
cross-tab: shared
cross-subdomain: shared where consent permits
campaign mid-session: does not reset
direct revisit: new direct session
version: 1

Generate an opaque session_id. Do not derive it from an IP address, email address, or browser fingerprint.

Persist acquisition fields#

Use first-party storage only after the relevant consent decision.

Session touch#

At the start of a session, determine:

{
  "session_source": "newsletter",
  "session_medium": "email",
  "session_campaign": "summer_launch"
}

Attach those fields to important session and conversion events.

First touch#

When no permitted first-touch record exists, store:

{
  "first_source": "google",
  "first_medium": "organic",
  "first_campaign": null,
  "first_touched_at": 1785081600
}

Do not overwrite first touch on every visit. Provide a deliberate reset policy for consent withdrawal, account deletion, or storage expiration.

Choose an attribution model#

Start with models that can be explained and reproduced:

Model Credit
First touch First known acquisition source
Session touch Source that began the converting session
Last non-direct Most recent known non-direct source before conversion
Linear Equal credit across recorded touches

GraphJSON does not provide a built-in data-driven attribution algorithm. If you implement multi-touch weighting, version the model and preserve the source touch sequence.

Do not compare two models without naming them in the chart title.

Query sessions by source#

SELECT
  JSONExtractString(json, 'session_source') AS source,
  uniqExact(JSONExtractString(json, 'session_id')) AS sessions
FROM web_events
WHERE
  JSONExtractString(json, 'event') = 'page_viewed'
  AND JSONExtractString(json, 'session_id') != ''
  AND toDateTime(timestamp) >= subtractDays(now(), 30)
GROUP BY source
ORDER BY sessions DESC
LIMIT 50

Query conversions by session touch#

SELECT
  JSONExtractString(json, 'session_source') AS source,
  JSONExtractString(json, 'session_medium') AS medium,
  uniqExact(JSONExtractString(json, 'account_id')) AS accounts
FROM web_events
WHERE
  JSONExtractString(json, 'event') = 'signup_completed'
  AND JSONExtractString(json, 'account_id') != ''
  AND toDateTime(timestamp) >= subtractDays(now(), 30)
GROUP BY source, medium
ORDER BY accounts DESC
LIMIT 100

This query attributes the conversion using properties captured on the conversion event. It does not reconstruct a touch path after the fact.

Query first-touch conversion#

SELECT
  JSONExtractString(json, 'first_source') AS first_source,
  uniqExact(JSONExtractString(json, 'account_id')) AS accounts
FROM web_events
WHERE
  JSONExtractString(json, 'event') = 'signup_completed'
  AND JSONExtractString(json, 'account_id') != ''
  AND toDateTime(timestamp) >= subtractDays(now(), 30)
GROUP BY first_source
ORDER BY accounts DESC

State whether the date filter applies to conversion time or acquisition time. The query above filters conversion time.

Filter internal and automated traffic#

Use controlled fields:

{
  "traffic_type": "external",
  "is_known_bot": false,
  "environment": "production"
}

Possible controls:

  • server-authenticated employee flag
  • internal-domain or test-account allowlist
  • explicit synthetic-monitor identifier
  • reviewed user-agent bot classification
  • rate and behavior heuristics

Do not treat IP-based filtering as perfect identity, and do not collect full IP addresses solely for analytics without a reviewed need.

Keep excluded traffic queryable in a separate collection or with an explicit field when validation matters. Silent deletion makes instrumentation harder to audit.

Single-page applications#

Emit page_viewed after the router confirms navigation. Normalize dynamic paths:

/projects/123  → /projects/:id
/users/456     → /users/:id

Keep the raw identifier in a dedicated allowed field only when the analysis needs it.

Avoid double-counting the first route by instrumenting both document load and router initialization.

Cross-domain behavior#

Cross-domain identity requires a deliberate design. Prefer:

  • authenticated user ID after sign-in
  • a short-lived, signed handoff token
  • shared first-party storage only where domains and policy allow it

Do not place a durable raw user ID, email address, or GraphJSON key in a link parameter.

If cross-domain tracking is not required, let each domain use an independent anonymous identity and document the boundary.

Before consent:

  • do not create analytics identifiers where policy prohibits them
  • do not queue disallowed events
  • do not persist campaign history

When consent is withdrawn:

  • stop new collection
  • clear disallowed local queues and attribution state
  • follow the organization’s deletion process

GraphJSON is not a consent-management platform. Your application owns the collection decision.

Validate the implementation#

Test:

  • direct first visit
  • external referral
  • UTM-tagged campaign
  • second session from another source
  • direct return
  • anonymous signup
  • authenticated conversion
  • internal employee
  • known synthetic monitor
  • consent denied and later granted
  • SPA navigation
  • cross-domain handoff if supported

For each test, inspect the exact event in Samples before trusting a dashboard.

Launch checklist#

  • Page paths are normalized and contain no secrets.
  • Query strings are allowlisted, not copied wholesale.
  • Referrers are reduced to safe hostnames.
  • Session start and timeout are documented.
  • First-touch and session-touch fields are distinct.
  • Attribution model is named in every metric.
  • Anonymous and authenticated identity rules are written.
  • Internal and automated traffic is explicit.
  • Consent controls collection and local persistence.
  • Conversion events come from the strongest available source.

Continue with Users, accounts, and identity, Metric governance, and Build a conversion funnel.

Need a hand?

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

Contact support