Ghizo Labs

Attribution Tracker

How the plugin works, how to set it up, and what to check when something looks wrong.

What this plugin does

It records how a visitor found the site, keeps that record for as long as they keep coming back, and attaches it to whatever they submit. The point is to answer one question: which campaign produced this lead?

Two records are kept per visitor, and they are not the same thing:

A single visit sets both to the same values. They only differ once somebody comes back through a different route.

Why it survives page caching

Everything is captured and stored in the browser. The plugin reads the URL and the referrer with JavaScript and writes the result to a first party cookie. No cookie is ever set by PHP, and nothing about a visitor is ever printed into the HTML.

That is what makes it safe behind a full page cache, a caching plugin, or a CDN. Two different visitors are served byte identical HTML, so there is nothing to go stale and nothing that can leak from one person to another.

The server only reads the cookie at the moment of conversion: a form submission or a checkout. Both of those are POST requests, which are never served from a cache, so the browser sends its cookies along with them.

Setting it up

  1. Check the settings. Under Attribution → Settings, confirm how long attribution should be remembered and add any custom query parameters your campaigns use.
  2. Add tracking fields to your forms. Under Attribution → Gravity Forms, pick the forms and the values you want, then apply. The fields are created hidden and already configured to fill themselves.
  3. Prove it. Under Attribution → Status, use Run a test visit, then reload the Status screen to see what your own browser has stored.

Gravity Forms

The Gravity Forms screen adds hidden fields to a form in bulk and removes them the same way. Each field is tagged with a token, and the plugin fills it in the browser before the form is submitted. Editors never have to touch them.

There is also a server side safety net. If the browser could not fill a field, for example because JavaScript was blocked, the stored values are still in the cookie sent with the submission, so they are copied in just before the entry is saved. A submitted value is never overwritten.

Form tokens

A token is a touch prefix plus a stored key: first_source, last_campaign, first_gclid. Every key listed under What gets captured exists twice, once per touch. The full list with descriptions is on the Status screen.

To wire a field by hand instead, set the token as the field's parameter name in Gravity Forms and turn on dynamic population.

Contact Form 7

Contact Form 7 stores nothing by default: a submission becomes an email and then it is gone. So the place attribution has to reach is the mail template rather than a saved entry.

Mail tags

Write any token in the mail template with a wpat_ prefix. It is resolved when the mail is sent, from the values that arrived with the submission.

Source:   [wpat_last_source]
Campaign: [wpat_first_campaign]
Channel:  [wpat_last_channel]

Every token on the Status screen works this way, so [wpat_first_gclid], [wpat_last_landing_page] and a custom parameter's token all behave the same.

A hidden field, when you want the value in the form

Add this to the form template. The tag's own name is the token:

[wpat_hidden last_source]

The submitted key is then the token itself, so [last_source] works in the mail like any other field. The input is rendered empty and filled in the browser, exactly as a Gravity Forms field is, which is what keeps the page cacheable. A name that is not a token renders no input at all, so a typo leaves nothing behind quietly posting an empty value.

The safety net covers these fields too. Any attribution field that arrives empty is filled from the cookie before the mail is built, controlled by Fill empty fields on submission in the settings, and a value the visitor actually submitted is never overwritten.

WPForms

Write a token as a smart tag, anywhere WPForms accepts text: a notification, a confirmation, or a stored entry on WPForms Pro.

{wpat_last_source}
{wpat_first_campaign}

Every token has one, prefixed with wpat_.

Smart tags rather than a hidden field, because of what the free version can actually do: WPForms' Hidden Field is a Pro only field and renders nothing on the free version, and WPForms Lite stores no entries at all, so a notification email is the only place a value can land without Pro.

Fluent Forms

Write a token as a smart code, anywhere Fluent Forms accepts text: an email notification, a confirmation message, or a webhook body.

{wpat.last_source}
{wpat.first_campaign}

Every token has one, inside the wpat group. A code that is not one of ours is handed straight back, so anything belonging to another plugin resolves exactly as it did before.

To keep the value on the entry rather than only in the email, add a Hidden Field and set its name to the token, last_source. It is submitted empty and filled on the server before the entry is stored, which is what keeps the page cacheable.

Ninja Forms

Write a token as a merge tag, in a notification, in an action's settings, or as a field's default value.

{wpat:last_source}
{wpat:first_campaign}

Every token has one, inside the wpat group.

For a value that reaches the submission itself, add a Hidden field, set its key to the token, last_source, and type the matching merge tag as the default value. Ninja Forms' merge tag picker only offers calculation tags on that field, so ours has to be typed by hand rather than picked from the list. It resolves the same way.

The safety net covers these fields too. Any attribution field that arrives empty is filled from the cookie before the submission is processed, controlled by Fill empty fields on submission in the settings.

Elementor Pro

Forms are a Pro feature, so none of this applies to Elementor's free version.

Write a token in the form's email message, in the same square bracket style Elementor uses for its own fields:

Source:   [wpat_last_source]
Campaign: [wpat_first_campaign]

Elementor's own [field id="..."] references are left alone, so the two sit together in one message without interfering.

For a value in the stored submission rather than only in the email, add a Hidden field and set its ID to the token, last_source. The plugin fills it from the cookie when it arrives empty.

One thing worth being straight about: this integration has not been run against a live Elementor Pro install, because Elementor Pro is a paid product and we do not hold a licence for it. It is built against Elementor's documented hooks and is covered by tests, but tests can only prove our half. The email tokens use a documented filter and are the reliable part. Filling a hidden field depends on modifying the submission record, which Elementor does not document, so the code checks whether that is possible before attempting it and does nothing at all when it is not. The worst case is a hidden field that stays empty, not a form that breaks. If you use Elementor Pro, telling us which of the two you see would be a real help.

WooCommerce

With WooCommerce active, the visitor's first and last touch are copied onto each order when it is placed, through the classic checkout or the checkout block. Open the order and the Attribution panel shows both.

The order keeps its own copy, so the attribution stays after the cookie expires. An order created in the admin is never attributed, so a phone order does not inherit the campaign the administrator happened to click.

What gets captured

Every stored key, per touch
Group Keys From
Campaign source, medium, campaign, term, content The matching utm_* parameter
Campaign detail campaign_id, source_platform, creative_format, marketing_tactic The newer GA4 parameters: utm_id and friends
Ad click IDs gclid, gbraid, wbraid, fbclid, msclkid The query parameter of the same name
Channel channel Worked out by the plugin, not read from the URL
Context landing_page, referrer, timestamp The visit itself

Any other query parameter can be added under Settings, and is then captured and exposed as a token exactly like the built in ones.

How a visit is classified

Every visit is given a channel, so a report can group traffic without needing every campaign to have been tagged perfectly. The possible values are Paid Search, Paid Social, Organic Search, Organic Social, Email, Referral, Direct and Other Campaign.

Explicit campaign data always wins over a guess. If the URL says utm_medium=cpc, that is what is used. Only when a visit carries no campaign data at all does the plugin fall back to inferring a channel from the referrer, and an advertising click ID on its own is enough to classify a visit as paid.

Checking that it works

The Status screen

Attribution → Status shows what is running, what your own browser has stored right now, and the full token reference. It is read on the server from the cookie your browser sent, so it is the real stored value rather than a rendering of it.

The debug panel

Turn it on from Attribution → Status with the Enable debug mode button, or add ?wpat_debug=1 to any front end URL. Either one opens a panel showing what was captured, which channel it was classified as, and what happened on that page view.

?wpat_debug=0, the Status screen's own toggle, and the panel's Stop debugging button all turn it off, because all three read and write the same local storage flag. Once on, it stays on across page views, and across closing the browser, until one of those actually turns it off; removing ?wpat_debug=1 from the address bar does not.

Access is administrators only. To test from a device you are not logged in on, turn on Allow the debug panel for logged out visitors in the settings, then turn it off again when you are done.

Troubleshooting

Nothing is captured at all

JavaScript did not run, or cookies are blocked. Open the browser console on a front end page and check for an error from another script: one failing script can stop the ones after it.

A form arrives with empty attribution fields

Check that Fill empty fields on submission is on in the settings, which is the server side safety net. If the field was added by hand, check that its parameter name is exactly a token from the Status screen and that dynamic population is enabled on the field.

Everything says Direct

Usually correct rather than broken. A visit with no campaign parameters and no external referrer is direct. Confirm with a test visit: if a tagged URL classifies properly, capture is working.

The other common cause is a redirect that drops the query string. If your ads point at a URL that redirects, make sure the redirect preserves utm_* parameters, or the campaign is gone before the page loads.

First touch keeps changing

First touch is only written when there is nothing stored. If it keeps moving, the cookie is being cleared between visits: check for a cookie consent tool removing it, or a privacy extension.

An order has no attribution

Expected for an order created in the admin or a renewal. For an order placed through the checkout, the visitor had nothing stored at that moment, most often because JavaScript was blocked for them.

Limits worth knowing

For developers

Reading the stored values in PHP

// One value. The touch comes second and defaults to last.
$source = wpat_value( 'source' );
$origin = wpat_value( 'source', 'first' );

// A whole touch, as key => value.
$last = wpat_touch( 'last' );

// Both touches at once.
$record = wpat_get();

// A channel value turned into something worth showing a person.
echo esc_html( wpat_channel_label( $last['channel'] ?? '' ) );

These read the cookie on the current request, so only call them somewhere that is not cached: a form handler, a checkout hook, an AJAX response. Calling them while rendering a cacheable page would bake one visitor's data into a page served to everybody.

Filters and actions

Extension points
Hook What it is for
wpat_tracked_params The query parameters captured on every page view
wpat_channel_rules The rules that classify a visit into a channel
wpat_script_config The configuration handed to the front end script
wpat_debug_config The debug panel's own configuration
wpat_debug_allowed Whether this request may see the debug panel
wpat_feature_enabled Switches one capability off for a build
wpat_register_features Action: add or replace a feature
wpat_loaded Action: fires once every feature has registered

Dropping a capability

Every part of the plugin is a feature that registers no hooks at all when it is off, so removing one costs nothing at runtime. The Status screen lists them.

add_filter( 'wpat_feature_enabled', function ( bool $enabled, string $id ): bool {
	return 'gravity-forms' === $id ? false : $enabled;
}, 10, 2 );

Where data is stored

Deactivating the plugin changes nothing. Deleting it removes its option. Values already written into form entries belong to those records and are left alone.