@thewishlistco/storefront-event-capture
v1.1.0
Published
JS capture tag for recording search events on Shopify storefronts. Captures search queries across all major search providers (Shopify native, Algolia, Klevu, Bloomreach) and sends them to the TWC ingest endpoint as `search_submitted` events.
Readme
@thewishlistco/storefront-event-capture
JS capture tag for recording search events on Shopify storefronts. Captures search queries across all major search providers (Shopify native, Algolia, Klevu, Bloomreach) and sends them to the TWC ingest endpoint as search_submitted events.
Installation
Retailers add the script tag to their Shopify theme (layout/theme.liquid) before </head>:
<script
src="https://d2n9tm43zyz9x3.cloudfront.net/capture.js?tenant=TENANT_ID"
defer
></script>Replace TENANT_ID with the tenant identifier provided by TWC at onboarding.
The CloudFront URL above is the one to hand to retailers. Do not point them at unpkg — that serves whatever was last
npm published, which is not necessarily what is on the CDN.
How it works
The script reads its own src URL to extract the tenant token — no server-side rendering required. It captures search queries using four complementary strategies:
| Strategy | Covers | Result count |
|----------|--------|--------------|
| URL query parameter (?q=) | Shopify native, most providers that redirect to a results page | Read from the rendered results page |
| Enter key on search input | Live search, autocomplete | Staged — see below |
| Form submit | Explicit search form submissions | Staged — see below |
| Algolia algolia:search custom event | Algolia InstantSearch | From nbHits |
Deduplication and result counts
A search typed into a form fires two strategies across a page navigation: the
keypress on the originating page, and the ?q= check on the results page. These
run in separate JS contexts, so in-memory dedup cannot see across them.
Dedup state therefore lives in sessionStorage, which is per-tab and survives
navigation. The same query is sent at most once per 30 seconds, which also covers
reloads and back-button returns to a results page.
The result count only exists on the results page, so the results page is the
canonical event for a navigating search. Enter/submit stage the query rather
than posting it; the results page then claims the staged query and posts it with
the count. If nothing navigates within 2.5 seconds — a live/AJAX search — the
originating page posts it instead. A staged query the shopper abandons is flushed
on the next page load with a null count rather than being dropped.
Counts are read from the theme's rendered results total (#ProductCountDesktop,
#ProductCount, .product-count__text, [data-results-count] — Dawn and the OS
2.0 themes derived from it). When no selector matches, resultCount is null;
the script never falls back to counting product cards, since a paginated grid
would undercount and a wrong number is worse than no number.
Identity
Each event carries identity so searches join the rest of the TWC clickstream:
| Field | Source |
|-------|--------|
| clientId | Shopify _shopify_y visitor cookie (the same value the Shopify web pixel sends as clientId). If absent, a first-party twc_vid cookie (UUID, 1 year). twc_vid is not created when Shopify's Customer Privacy API reports analytics declined — the event is then sent without clientId. |
| CustomerId.id | Logged-in Shopify customer id: window.TWC_CUSTOMER.id, else ShopifyAnalytics.meta.page.customerId, else __st.cid. |
| CustomerId.email | Only when the theme injects it (optional), before the script tag: |
{% if customer %}
<script>window.TWC_CUSTOMER = { id: {{ customer.id | json }}, email: {{ customer.email | json }} };</script>
{% endif %}Anonymous searches are attributed to the customer later via clientId (ClickHouse clickstream_resolved).
Events are POSTed to the TWC Lambda ingest endpoint:
POST https://4ag6zmnqub.execute-api.ap-southeast-2.amazonaws.com/getCustomerEvents/Development
npm install
npm run dev # watch mode
npm run build # produces dist/capture.jsPublishing
npm publish # runs build automatically via prepublishOnlyThe compiled dist/capture.js is what gets deployed. The source in src/ is
TypeScript and is not included in the published package.
Deploying
The tag is served from S3 behind CloudFront at a stable URL — retailers hardcode
it into theme.liquid, so the filename never changes and the CDN copy is mutable.
That makes an upload alone insufficient: CloudFront caches per edge location, so
POPs that already hold the previous object keep serving it until their TTL expires.
npm run build
aws s3 cp dist/capture.js s3://<bucket>/capture.js \
--content-type 'application/javascript' \
--cache-control 'public, max-age=300'
aws cloudfront create-invalidation \
--distribution-id <DISTRIBUTION_ID> --paths '/capture.js*'--paths '/capture.js*' rather than /capture.js so the invalidation also covers
the per-tenant query-string variants if the distribution caches on query strings.
Setting Cache-Control matters as much as the invalidation. With no Cache-Control
and no Expires, CloudFront falls back to the distribution's Default TTL (24h by
default) and browsers apply heuristic freshness derived from Last-Modified,
which can hold a stale tag in shoppers' caches for days. A short max-age keeps
fixes propagating in minutes at negligible origin cost for a ~3 kB file.
Verifying a deploy
The S3 ETag is the md5 of the object, so compare it against the local build:
md5 -q dist/capture.js
curl -sI 'https://d2n9tm43zyz9x3.cloudfront.net/capture.js' | grep -i 'etag\|cache-control\|x-cache'Matching etag means the edge is serving the build you just made. Re-run against a
few curl --resolve targets, or simply retry until x-cache: Hit from cloudfront,
to confirm propagation rather than a single cold edge.
