@tracelog/lib 2.9.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/browser/tracelog-shopify-pixel.iife.js +1 -1
- package/dist/browser/tracelog-shopify-pixel.iife.js.map +1 -1
- package/dist/browser/tracelog.esm.js +663 -451
- package/dist/browser/tracelog.esm.js.map +1 -1
- package/dist/browser/tracelog.js +2 -2
- package/dist/browser/tracelog.js.map +1 -1
- package/dist/pixel/index.cjs +2 -2
- package/dist/pixel/index.cjs.map +1 -1
- package/dist/pixel/index.js +2 -2
- package/dist/pixel/index.js.map +1 -1
- package/dist/public-api.cjs +2 -2
- package/dist/public-api.cjs.map +1 -1
- package/dist/public-api.d.mts +159 -7
- package/dist/public-api.d.ts +159 -7
- package/dist/public-api.js +2 -2
- package/dist/public-api.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -127,7 +127,9 @@ tracelog.destroy();
|
|
|
127
127
|
| Method | Description |
|
|
128
128
|
|--------|-------------|
|
|
129
129
|
| `init(config?)` | Initialize tracking (see [Configuration](#configuration)) |
|
|
130
|
-
| `event(name, metadata?)` | Track custom events |
|
|
130
|
+
| `event(name, metadata?, options?)` | Track custom events. `options.critical: true` drains the queue via `sendBeacon` right after tracking, so the batch (the critical event + anything already queued) survives an imminent navigation. Subject to `sendBeacon`'s 64KB cap — oversized batches are persisted to `localStorage` and recovered on next `init()` via their idempotency token; the backend deduplicates by `event.id`. See [API_REFERENCE.md](./API_REFERENCE.md#eventname-metadata-options) for the full contract. |
|
|
131
|
+
| `flushImmediately()` | Force an async `fetch` flush of all pending events. Returns `Promise<boolean>`. |
|
|
132
|
+
| `flushImmediatelySync()` | Force a `sendBeacon` flush. Use for custom unload handlers; the library already wires this to `pagehide`/`beforeunload`/`visibilitychange`. |
|
|
131
133
|
| `updateGlobalMetadata(metadata)` | Replace all global metadata |
|
|
132
134
|
| `mergeGlobalMetadata(metadata)` | Merge with existing global metadata |
|
|
133
135
|
| `on(event, callback)` | Subscribe to events (`'event'` or `'queue'`) |
|
|
@@ -189,6 +191,10 @@ await tracelog.init({
|
|
|
189
191
|
samplingRate: 1.0, // 100% (default)
|
|
190
192
|
sensitiveQueryParams: ['token'], // Add to defaults
|
|
191
193
|
|
|
194
|
+
// Flush behavior (defaults shown)
|
|
195
|
+
flushOnSpaNavigation: false, // Opt-in: flush after pushState/replaceState/popstate/hashchange (default false; per-route flushing multiplies request volume on SPAs)
|
|
196
|
+
flushOnPageHidden: true, // Flush when document.hidden becomes true (mobile Safari coverage)
|
|
197
|
+
|
|
192
198
|
// Integrations (pick one, multiple, or none)
|
|
193
199
|
integrations: {
|
|
194
200
|
tracelog: { projectId: 'your-id' }, // TraceLog SaaS
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var TraceLogShopifyPixel=function(t){"use strict";const e=["cart_viewed","checkout_started","checkout_contact_info_submitted","checkout_address_info_submitted","checkout_shipping_info_submitted","payment_info_submitted","checkout_completed"],n=e.map(t=>`shopify_${t}`);function i(t,e){if(!Array.isArray(t))return null;for(const n of t)if(n.key===e&&"string"==typeof n.value&&n.value.length>0)return n.value;return null}function o(t){return"number"==typeof t&&Number.isFinite(t)?t:void 0}function c(t){return"string"==typeof t&&t.length>0?t:void 0}function r(t,e,n){void 0!==n&&(t[e]=n)}function u(t,e){const n=t.data?.checkout,i=t.data?.cart,u={};r(u,"shopify_client_id",c(t.clientId));const
|
|
1
|
+
var TraceLogShopifyPixel=function(t){"use strict";const e=["cart_viewed","checkout_started","checkout_contact_info_submitted","checkout_address_info_submitted","checkout_shipping_info_submitted","payment_info_submitted","checkout_completed"],n=e.map(t=>`shopify_${t}`);function i(t,e){if(!Array.isArray(t))return null;for(const n of t)if(n.key===e&&"string"==typeof n.value&&n.value.length>0)return n.value;return null}function o(t){return"number"==typeof t&&Number.isFinite(t)?t:void 0}function c(t){return"string"==typeof t&&t.length>0?t:void 0}function r(t,e,n){void 0!==n&&(t[e]=n)}function u(t,e){const n=t.data?.checkout,i=t.data?.cart,u={};r(u,"shopify_client_id",c(t.clientId));const l=c(n?.token);if(void 0!==l&&(u.checkout_token=l),"checkout_completed"===e){r(u,"value",o(n?.totalPrice?.amount??n?.subtotalPrice?.amount)),r(u,"currency",c(n?.currencyCode??n?.totalPrice?.currencyCode));const t=n?.order?.id;"string"!=typeof t&&"number"!=typeof t||(u.orderId=String(t));const e=n?.lineItems??[],i=function(t){if(!t||0===t.length)return[];const e=t.slice(0,100);return e.map(t=>{const e={},n=t.variant?.id;return"string"!=typeof n&&"number"!=typeof n||(e.id=String(n)),r(e,"title",_(f(c(t.title??t.variant?.product?.title)),a)),r(e,"quantity",o(t.quantity)),r(e,"price",o(t.variant?.price?.amount??t.finalLinePrice?.amount)),r(e,"sku",_(f(c(t.variant?.sku??void 0)),d)),r(e,"vendor",_(f(c(t.variant?.product?.vendor)),a)),e})}(e);i.length>0&&(u.items=i,e.length>100&&(u.items_truncated=!0))}else if("cart_viewed"===e){r(u,"cart_total",o(i?.cost?.totalAmount?.amount??i?.totalAmount?.amount));r(u,"item_count",s(i?.lines)??s(i?.lineItems))}else if("checkout_started"===e){r(u,"cart_total",o(n?.totalPrice?.amount??n?.subtotalPrice?.amount)),r(u,"currency",c(n?.currencyCode??n?.totalPrice?.currencyCode));r(u,"item_count",s(n?.lineItems)??s(t.data?.checkoutLineItems))}else{r(u,"currency",c(n?.currencyCode??n?.totalPrice?.currencyCode));const t=n?.totalPrice?.amount;r(u,"cart_total",o(t))}return u}function s(t){if(!t||0===t.length)return;let e=0,n=!1;for(const i of t){const t=o(i.quantity);void 0!==t&&(n=!0,e+=t)}return n?e:t.length}const a=255,d=128,l=[/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,/javascript:/gi,/on\w+\s*=/gi,/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi,/<embed\b[^>]*>/gi,/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi];function f(t){if(void 0===t)return;let e=t;for(const n of l)e=e.replace(n,"");return e}function _(t,e){if(void 0!==t)return t.length>e?t.slice(0,e):t}function m(t){const e=c(t.context?.window?.location?.href)??c(t.context?.document?.location?.href);return void 0!==e?function(t){const e=t.indexOf("?"),n=t.indexOf("#");let i=t.length;return-1!==e&&(i=e),-1!==n&&n<i&&(i=n),t.slice(0,i)}(e):"unknown"}function p(t,e){if(!t)return null;const n=t.data?.checkout?.attributes,o=t.data?.cart?.attributes,r=i(n,"tracelog_session_id")??i(o,"tracelog_session_id");if(null===r)return null;const s=i(n,"tracelog_user_id")??i(o,"tracelog_user_id")??c(t.clientId);if(void 0===s)return null;const a=function(t){if("string"!=typeof t||0===t.length)return Date.now();const e=Date.parse(t);return Number.isFinite(e)?e:Date.now()}(t.timestamp),d=function(t,e){return`${e}-001-${c(t.id)?.slice(-6)??Math.random().toString(36).slice(2,8)}`}(t,a),l=u(t,e);return{user_id:s,session_id:r,device:{type:"unknown",os:"unknown",browser:"unknown"},events:[{id:d,type:"custom",page_url:m(t),timestamp:a,custom_event:{name:`shopify_${e}`,metadata:l}}],_metadata:{client_version:"shopify-web-pixel-1",timestamp:a}}}function h(t,e){if(!t.projectId)return;const n=`https://ingest.tracelog.io/p/${encodeURIComponent(t.projectId)}/collect`;try{fetch(n,{method:"POST",headers:{"Content-Type":"application/json"},keepalive:!0,body:JSON.stringify(e)}).catch(()=>{})}catch{}}return t.SHOPIFY_EVENTS=e,t.SHOPIFY_PIXEL_EVENT_NAMES=n,t.mapEventToBody=p,t.registerShopifyPixel=function(t,e){const n=(t,n)=>{const i=p(n,t);i&&h(e,i)};t.analytics.subscribe("cart_viewed",t=>{n("cart_viewed",t)}),t.analytics.subscribe("checkout_started",t=>{n("checkout_started",t)}),t.analytics.subscribe("checkout_contact_info_submitted",t=>{n("checkout_contact_info_submitted",t)}),t.analytics.subscribe("checkout_address_info_submitted",t=>{n("checkout_address_info_submitted",t)}),t.analytics.subscribe("checkout_shipping_info_submitted",t=>{n("checkout_shipping_info_submitted",t)}),t.analytics.subscribe("payment_info_submitted",t=>{n("payment_info_submitted",t)}),t.analytics.subscribe("checkout_completed",t=>{n("checkout_completed",t)})},t.sendBatch=h,Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t}({});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tracelog-shopify-pixel.iife.js","sources":["../../src/pixel/event-mapper.ts","../../src/pixel/pixel-sender.ts","../../src/pixel/shopify-pixel.ts"],"sourcesContent":["import type { PixelEventBody } from './pixel-sender';\n\nconst PIXEL_CLIENT_VERSION = 'shopify-web-pixel-1';\n\nconst MAX_LINE_ITEMS = 100;\n\nexport const SHOPIFY_EVENTS = [\n 'cart_viewed',\n 'checkout_started',\n 'checkout_contact_info_submitted',\n 'checkout_address_info_submitted',\n 'checkout_shipping_info_submitted',\n 'payment_info_submitted',\n 'checkout_completed',\n] as const;\n\nexport type ShopifyEventName = (typeof SHOPIFY_EVENTS)[number];\n\n/**\n * Final event names emitted by `mapEventToBody()`. Exported so consumers\n * (tracelog-api event-catalog discovery, dashboards, contract validators)\n * can recognise pixel events without re-deriving the `shopify_` prefix.\n */\nexport const SHOPIFY_PIXEL_EVENT_NAMES = SHOPIFY_EVENTS.map((name) => `shopify_${name}` as const);\n\nexport type ShopifyPixelEventName = (typeof SHOPIFY_PIXEL_EVENT_NAMES)[number];\n\n/**\n * Spike-confirmed shape ([04-spike-report.md](../../docs/tasks/shopify-hybrid-capture/04-spike-report.md)):\n * `event.data.{cart,checkout}.attributes` is `Array<{key, value, __typename?}>`,\n * NOT a plain object. `__typename: 'NoteAttribute'` is added on `checkout_completed`\n * only — harmless because we lookup by `key`.\n */\ninterface ShopifyAttribute {\n key?: string;\n value?: string;\n __typename?: string;\n}\n\ninterface ShopifyMoney {\n amount?: number;\n currencyCode?: string;\n}\n\ninterface ShopifyLineItemVariantProduct {\n id?: string | number;\n title?: string;\n vendor?: string;\n}\n\ninterface ShopifyLineItemVariant {\n id?: string | number;\n sku?: string | null;\n price?: ShopifyMoney;\n product?: ShopifyLineItemVariantProduct;\n}\n\ninterface ShopifyLineItem {\n id?: string | number;\n title?: string;\n quantity?: number;\n variant?: ShopifyLineItemVariant;\n finalLinePrice?: ShopifyMoney;\n}\n\ninterface ShopifyCheckout {\n token?: string;\n currencyCode?: string;\n totalPrice?: ShopifyMoney;\n subtotalPrice?: ShopifyMoney;\n attributes?: ShopifyAttribute[];\n lineItems?: ShopifyLineItem[];\n order?: { id?: string | number };\n}\n\ninterface ShopifyCart {\n attributes?: ShopifyAttribute[];\n totalAmount?: ShopifyMoney;\n cost?: { totalAmount?: ShopifyMoney };\n lines?: ShopifyLineItem[];\n lineItems?: ShopifyLineItem[];\n}\n\ninterface ShopifyCheckoutLineItem {\n quantity?: number;\n}\n\ninterface ShopifyEvent {\n id?: string;\n timestamp?: string;\n clientId?: string;\n data?: {\n checkout?: ShopifyCheckout;\n cart?: ShopifyCart;\n checkoutLineItems?: ShopifyCheckoutLineItem[];\n };\n context?: {\n window?: { location?: { href?: string } };\n document?: { location?: { href?: string } };\n };\n}\n\nfunction attrLookup(attrs: ShopifyAttribute[] | undefined, key: string): string | null {\n if (!Array.isArray(attrs)) return null;\n for (const a of attrs) {\n if (a.key === key && typeof a.value === 'string' && a.value.length > 0) return a.value;\n }\n return null;\n}\n\nfunction safeNumber(value: unknown): number | undefined {\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined;\n}\n\nfunction safeString(value: unknown): string | undefined {\n return typeof value === 'string' && value.length > 0 ? value : undefined;\n}\n\nfunction setIfDefined<T>(target: Record<string, unknown>, key: string, value: T | undefined): void {\n if (value !== undefined) target[key] = value;\n}\n\nfunction buildMetadata(event: ShopifyEvent, name: ShopifyEventName): Record<string, unknown> {\n const checkout = event.data?.checkout;\n const cart = event.data?.cart;\n const meta: Record<string, unknown> = {};\n\n setIfDefined(meta, 'shopify_client_id', safeString(event.clientId));\n\n const checkoutToken = safeString(checkout?.token);\n if (checkoutToken !== undefined) meta['checkout_token'] = checkoutToken;\n\n if (name === 'checkout_completed') {\n const total = checkout?.totalPrice?.amount ?? checkout?.subtotalPrice?.amount;\n setIfDefined(meta, 'value', safeNumber(total));\n setIfDefined(meta, 'currency', safeString(checkout?.currencyCode ?? checkout?.totalPrice?.currencyCode));\n const orderId = checkout?.order?.id;\n if (typeof orderId === 'string' || typeof orderId === 'number') meta['orderId'] = String(orderId);\n const rawItems = checkout?.lineItems ?? [];\n const items = mapLineItems(rawItems);\n if (items.length > 0) {\n meta['items'] = items;\n if (rawItems.length > MAX_LINE_ITEMS) meta['items_truncated'] = true;\n }\n } else if (name === 'cart_viewed') {\n const cartTotal = cart?.cost?.totalAmount?.amount ?? cart?.totalAmount?.amount;\n setIfDefined(meta, 'cart_total', safeNumber(cartTotal));\n const itemCount = countLineItems(cart?.lines) ?? countLineItems(cart?.lineItems);\n setIfDefined(meta, 'item_count', itemCount);\n } else if (name === 'checkout_started') {\n const total = checkout?.totalPrice?.amount ?? checkout?.subtotalPrice?.amount;\n setIfDefined(meta, 'cart_total', safeNumber(total));\n setIfDefined(meta, 'currency', safeString(checkout?.currencyCode ?? checkout?.totalPrice?.currencyCode));\n const itemCount = countLineItems(checkout?.lineItems) ?? countLineItems(event.data?.checkoutLineItems);\n setIfDefined(meta, 'item_count', itemCount);\n } else {\n setIfDefined(meta, 'currency', safeString(checkout?.currencyCode ?? checkout?.totalPrice?.currencyCode));\n const total = checkout?.totalPrice?.amount;\n setIfDefined(meta, 'cart_total', safeNumber(total));\n }\n\n return meta;\n}\n\nfunction countLineItems(items: { quantity?: number }[] | undefined): number | undefined {\n if (!items || items.length === 0) return undefined;\n let count = 0;\n let hasNumericQuantity = false;\n for (const item of items) {\n const q = safeNumber(item.quantity);\n if (q !== undefined) {\n hasNumericQuantity = true;\n count += q;\n }\n }\n // Fallback to row count only when no line had a numeric quantity. An explicit\n // sum of zero (e.g. `[{ quantity: 0 }]`) must be preserved, not replaced with\n // `items.length`, otherwise empty carts over-report `item_count`.\n return hasNumericQuantity ? count : items.length;\n}\n\n// Caps merchant-controlled free-text to keep payloads under the API's per-string DTO limit.\n// IDs, tokens, and currency codes are naturally bounded by Shopify and skip capping.\nconst MAX_TEXT_LEN = 255;\nconst MAX_SKU_LEN = 128;\n\n// Mirrors `src/constants/config.constants.ts` `XSS_PATTERNS`. Inlined because\n// the constants module pulls in unrelated dependencies that would blow the\n// pixel bundle's 5KB budget. Defense-in-depth: Shopify product titles/SKUs/\n// vendors are merchant-controlled; a compromised admin could inject HTML/JS\n// that lands in dashboards or logs.\nconst XSS_PATTERNS: readonly RegExp[] = [\n /<script\\b[^<]*(?:(?!<\\/script>)<[^<]*)*<\\/script>/gi,\n /javascript:/gi,\n /on\\w+\\s*=/gi,\n /<iframe\\b[^<]*(?:(?!<\\/iframe>)<[^<]*)*<\\/iframe>/gi,\n /<embed\\b[^>]*>/gi,\n /<object\\b[^<]*(?:(?!<\\/object>)<[^<]*)*<\\/object>/gi,\n];\n\nfunction sanitize(value: string | undefined): string | undefined {\n if (value === undefined) return undefined;\n let out = value;\n for (const pattern of XSS_PATTERNS) out = out.replace(pattern, '');\n return out;\n}\n\nfunction cap(value: string | undefined, max: number): string | undefined {\n if (value === undefined) return undefined;\n return value.length > max ? value.slice(0, max) : value;\n}\n\nfunction mapLineItems(items: ShopifyLineItem[] | undefined): Record<string, unknown>[] {\n if (!items || items.length === 0) return [];\n const capped = items.slice(0, MAX_LINE_ITEMS);\n return capped.map((item) => {\n const out: Record<string, unknown> = {};\n const variantId = item.variant?.id;\n if (typeof variantId === 'string' || typeof variantId === 'number') {\n out['id'] = String(variantId);\n }\n setIfDefined(out, 'title', cap(sanitize(safeString(item.title ?? item.variant?.product?.title)), MAX_TEXT_LEN));\n setIfDefined(out, 'quantity', safeNumber(item.quantity));\n setIfDefined(out, 'price', safeNumber(item.variant?.price?.amount ?? item.finalLinePrice?.amount));\n setIfDefined(out, 'sku', cap(sanitize(safeString(item.variant?.sku ?? undefined)), MAX_SKU_LEN));\n setIfDefined(out, 'vendor', cap(sanitize(safeString(item.variant?.product?.vendor)), MAX_TEXT_LEN));\n return out;\n });\n}\n\nfunction generateEventId(event: ShopifyEvent, ts: number): string {\n const suffix = safeString(event.id)?.slice(-6) ?? Math.random().toString(36).slice(2, 8);\n return `${ts}-001-${suffix}`;\n}\n\nfunction resolveTimestamp(value: string | undefined): number {\n if (typeof value !== 'string' || value.length === 0) return Date.now();\n const parsed = Date.parse(value);\n return Number.isFinite(parsed) ? parsed : Date.now();\n}\n\nfunction stripUrlParams(href: string): string {\n // Pixel runs in the checkout sandbox where href can carry recovery tokens\n // (`?recovery=...`, `?key=...`). Path alone identifies the funnel stage; drop\n // query and hash to keep tokens out of telemetry. Mirrors the policy in\n // `src/utils/network/url.utils.ts` (which is too heavy to import into the\n // sub-5KB pixel bundle).\n const queryIdx = href.indexOf('?');\n const hashIdx = href.indexOf('#');\n let cutoff = href.length;\n if (queryIdx !== -1) cutoff = queryIdx;\n if (hashIdx !== -1 && hashIdx < cutoff) cutoff = hashIdx;\n return href.slice(0, cutoff);\n}\n\nfunction resolvePageUrl(event: ShopifyEvent): string {\n const href = safeString(event.context?.window?.location?.href) ?? safeString(event.context?.document?.location?.href);\n return href !== undefined ? stripUrlParams(href) : 'unknown';\n}\n\nexport function mapEventToBody(\n shopifyEvent: ShopifyEvent | null | undefined,\n shopifyEventName: ShopifyEventName,\n): PixelEventBody | null {\n if (!shopifyEvent) return null;\n\n const checkoutAttrs = shopifyEvent.data?.checkout?.attributes;\n const cartAttrs = shopifyEvent.data?.cart?.attributes;\n\n const sessionId = attrLookup(checkoutAttrs, 'tracelog_session_id') ?? attrLookup(cartAttrs, 'tracelog_session_id');\n const userId = attrLookup(checkoutAttrs, 'tracelog_user_id') ?? attrLookup(cartAttrs, 'tracelog_user_id');\n\n if (sessionId === null || userId === null) return null;\n\n const ts = resolveTimestamp(shopifyEvent.timestamp);\n const eventId = generateEventId(shopifyEvent, ts);\n const metadata = buildMetadata(shopifyEvent, shopifyEventName);\n\n return {\n user_id: userId,\n session_id: sessionId,\n device: { type: 'unknown', os: 'unknown', browser: 'unknown' },\n events: [\n {\n id: eventId,\n type: 'custom',\n page_url: resolvePageUrl(shopifyEvent),\n timestamp: ts,\n custom_event: {\n name: `shopify_${shopifyEventName}`,\n metadata,\n },\n },\n ],\n _metadata: {\n client_version: PIXEL_CLIENT_VERSION,\n timestamp: ts,\n },\n };\n}\n","/**\n * Standalone HTTP sender for the Shopify Web Pixel Extension bundle.\n *\n * Posts to the path-based ingress (`ingest.tracelog.io/p/<id>/collect`) — NOT\n * `api.tracelog.io/events/collect` — because the middleware has the only CORS\n * handler that accepts `Origin: null` from sandboxed iframes.\n *\n * Best-effort: failures are silently swallowed. The webhook (Task 03) carries\n * the revenue contract; pixel events are funnel-only and accept ~5-30% loss.\n */\n\nconst INGEST_HOST = 'https://ingest.tracelog.io';\n\nexport interface PixelSenderSettings {\n /** TraceLog project identifier (e.g. `t756edc0pnn17ha7`). Provided by Shopify init payload. */\n projectId: string;\n}\n\nexport interface PixelEventBody {\n user_id: string;\n session_id: string;\n device: { type: string; os: string; browser: string };\n events: Array<{\n id: string;\n type: 'custom';\n page_url: string;\n timestamp: number;\n custom_event: { name: string; metadata: Record<string, unknown> };\n }>;\n _metadata: { client_version: string; timestamp: number };\n}\n\nexport function sendBatch(settings: PixelSenderSettings, body: PixelEventBody): void {\n // Trust boundary is the Shopify extension settings form, but a misconfigured\n // (empty) projectId would yield `https://ingest.tracelog.io/p//collect` and\n // 404 every event. Drop silently so it can be diagnosed via Shopify pixel logs.\n if (!settings.projectId) return;\n\n // Encode in case the merchant pastes whitespace, slashes, or other unsafe\n // characters into the Shopify extension settings form.\n const url = `${INGEST_HOST}/p/${encodeURIComponent(settings.projectId)}/collect`;\n try {\n void fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n keepalive: true,\n body: JSON.stringify(body),\n }).catch(() => {});\n } catch {\n // Pixel runs inside Shopify's strict sandbox; fetch() may be unavailable\n // or throw synchronously. Funnel events are best-effort — drop silently.\n }\n}\n","import { mapEventToBody, type ShopifyEventName } from './event-mapper';\nimport { sendBatch, type PixelSenderSettings } from './pixel-sender';\n\ninterface ShopifyAnalyticsApi {\n subscribe: (event: ShopifyEventName, callback: (event: unknown) => void) => void;\n}\n\ninterface ShopifyPixelApi {\n analytics: ShopifyAnalyticsApi;\n}\n\n/**\n * Registers the TraceLog Shopify Web Pixel: subscribes to the 7 standard\n * Customer Events and forwards each one to `ingest.tracelog.io/p/<id>/collect`.\n *\n * Each subscription is declared explicitly because Shopify's static analyzer\n * inspects the bundle for `analytics.subscribe('event_name', ...)` calls;\n * loop-based subscriptions are flagged and the pixel is treated as inactive.\n */\nexport function registerShopifyPixel(api: ShopifyPixelApi, settings: PixelSenderSettings): void {\n const handle = (eventName: ShopifyEventName, payload: unknown): void => {\n const body = mapEventToBody(payload as Parameters<typeof mapEventToBody>[0], eventName);\n if (!body) return;\n sendBatch(settings, body);\n };\n\n api.analytics.subscribe('cart_viewed', (event) => {\n handle('cart_viewed', event);\n });\n api.analytics.subscribe('checkout_started', (event) => {\n handle('checkout_started', event);\n });\n api.analytics.subscribe('checkout_contact_info_submitted', (event) => {\n handle('checkout_contact_info_submitted', event);\n });\n api.analytics.subscribe('checkout_address_info_submitted', (event) => {\n handle('checkout_address_info_submitted', event);\n });\n api.analytics.subscribe('checkout_shipping_info_submitted', (event) => {\n handle('checkout_shipping_info_submitted', event);\n });\n api.analytics.subscribe('payment_info_submitted', (event) => {\n handle('payment_info_submitted', event);\n });\n api.analytics.subscribe('checkout_completed', (event) => {\n handle('checkout_completed', event);\n });\n}\n"],"names":["SHOPIFY_EVENTS","SHOPIFY_PIXEL_EVENT_NAMES","map","name","attrLookup","attrs","key","Array","isArray","a","value","length","safeNumber","Number","isFinite","safeString","setIfDefined","target","buildMetadata","event","checkout","data","cart","meta","clientId","checkoutToken","token","totalPrice","amount","subtotalPrice","currencyCode","orderId","order","id","String","rawItems","lineItems","items","capped","slice","item","out","variantId","variant","cap","sanitize","title","product","MAX_TEXT_LEN","quantity","price","finalLinePrice","sku","MAX_SKU_LEN","vendor","mapLineItems","cost","totalAmount","countLineItems","lines","checkoutLineItems","total","count","hasNumericQuantity","q","XSS_PATTERNS","pattern","replace","max","resolvePageUrl","href","context","window","location","document","queryIdx","indexOf","hashIdx","cutoff","stripUrlParams","mapEventToBody","shopifyEvent","shopifyEventName","checkoutAttrs","attributes","cartAttrs","sessionId","userId","ts","Date","now","parsed","parse","resolveTimestamp","timestamp","eventId","Math","random","toString","generateEventId","metadata","user_id","session_id","device","type","os","browser","events","page_url","custom_event","_metadata","client_version","sendBatch","settings","body","projectId","url","encodeURIComponent","fetch","method","headers","keepalive","JSON","stringify","catch","api","handle","eventName","payload","analytics","subscribe"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"tracelog-shopify-pixel.iife.js","sources":["../../src/pixel/event-mapper.ts","../../src/pixel/pixel-sender.ts","../../src/pixel/shopify-pixel.ts"],"sourcesContent":["import type { PixelEventBody } from './pixel-sender';\n\nconst PIXEL_CLIENT_VERSION = 'shopify-web-pixel-1';\n\nconst MAX_LINE_ITEMS = 100;\n\nexport const SHOPIFY_EVENTS = [\n 'cart_viewed',\n 'checkout_started',\n 'checkout_contact_info_submitted',\n 'checkout_address_info_submitted',\n 'checkout_shipping_info_submitted',\n 'payment_info_submitted',\n 'checkout_completed',\n] as const;\n\nexport type ShopifyEventName = (typeof SHOPIFY_EVENTS)[number];\n\n/**\n * Final event names emitted by `mapEventToBody()`. Exported so consumers\n * (tracelog-api event-catalog discovery, dashboards, contract validators)\n * can recognise pixel events without re-deriving the `shopify_` prefix.\n */\nexport const SHOPIFY_PIXEL_EVENT_NAMES = SHOPIFY_EVENTS.map((name) => `shopify_${name}` as const);\n\nexport type ShopifyPixelEventName = (typeof SHOPIFY_PIXEL_EVENT_NAMES)[number];\n\n/**\n * Spike-confirmed shape ([04-spike-report.md](../../docs/tasks/shopify-hybrid-capture/04-spike-report.md)):\n * `event.data.{cart,checkout}.attributes` is `Array<{key, value, __typename?}>`,\n * NOT a plain object. `__typename: 'NoteAttribute'` is added on `checkout_completed`\n * only — harmless because we lookup by `key`.\n */\ninterface ShopifyAttribute {\n key?: string;\n value?: string;\n __typename?: string;\n}\n\ninterface ShopifyMoney {\n amount?: number;\n currencyCode?: string;\n}\n\ninterface ShopifyLineItemVariantProduct {\n id?: string | number;\n title?: string;\n vendor?: string;\n}\n\ninterface ShopifyLineItemVariant {\n id?: string | number;\n sku?: string | null;\n price?: ShopifyMoney;\n product?: ShopifyLineItemVariantProduct;\n}\n\ninterface ShopifyLineItem {\n id?: string | number;\n title?: string;\n quantity?: number;\n variant?: ShopifyLineItemVariant;\n finalLinePrice?: ShopifyMoney;\n}\n\ninterface ShopifyCheckout {\n token?: string;\n currencyCode?: string;\n totalPrice?: ShopifyMoney;\n subtotalPrice?: ShopifyMoney;\n attributes?: ShopifyAttribute[];\n lineItems?: ShopifyLineItem[];\n order?: { id?: string | number };\n}\n\ninterface ShopifyCart {\n attributes?: ShopifyAttribute[];\n totalAmount?: ShopifyMoney;\n cost?: { totalAmount?: ShopifyMoney };\n lines?: ShopifyLineItem[];\n lineItems?: ShopifyLineItem[];\n}\n\ninterface ShopifyCheckoutLineItem {\n quantity?: number;\n}\n\ninterface ShopifyEvent {\n id?: string;\n timestamp?: string;\n clientId?: string;\n data?: {\n checkout?: ShopifyCheckout;\n cart?: ShopifyCart;\n checkoutLineItems?: ShopifyCheckoutLineItem[];\n };\n context?: {\n window?: { location?: { href?: string } };\n document?: { location?: { href?: string } };\n };\n}\n\nfunction attrLookup(attrs: ShopifyAttribute[] | undefined, key: string): string | null {\n if (!Array.isArray(attrs)) return null;\n for (const a of attrs) {\n if (a.key === key && typeof a.value === 'string' && a.value.length > 0) return a.value;\n }\n return null;\n}\n\nfunction safeNumber(value: unknown): number | undefined {\n return typeof value === 'number' && Number.isFinite(value) ? value : undefined;\n}\n\nfunction safeString(value: unknown): string | undefined {\n return typeof value === 'string' && value.length > 0 ? value : undefined;\n}\n\nfunction setIfDefined<T>(target: Record<string, unknown>, key: string, value: T | undefined): void {\n if (value !== undefined) target[key] = value;\n}\n\nfunction buildMetadata(event: ShopifyEvent, name: ShopifyEventName): Record<string, unknown> {\n const checkout = event.data?.checkout;\n const cart = event.data?.cart;\n const meta: Record<string, unknown> = {};\n\n setIfDefined(meta, 'shopify_client_id', safeString(event.clientId));\n\n const checkoutToken = safeString(checkout?.token);\n if (checkoutToken !== undefined) meta['checkout_token'] = checkoutToken;\n\n if (name === 'checkout_completed') {\n const total = checkout?.totalPrice?.amount ?? checkout?.subtotalPrice?.amount;\n setIfDefined(meta, 'value', safeNumber(total));\n setIfDefined(meta, 'currency', safeString(checkout?.currencyCode ?? checkout?.totalPrice?.currencyCode));\n const orderId = checkout?.order?.id;\n if (typeof orderId === 'string' || typeof orderId === 'number') meta['orderId'] = String(orderId);\n const rawItems = checkout?.lineItems ?? [];\n const items = mapLineItems(rawItems);\n if (items.length > 0) {\n meta['items'] = items;\n if (rawItems.length > MAX_LINE_ITEMS) meta['items_truncated'] = true;\n }\n } else if (name === 'cart_viewed') {\n const cartTotal = cart?.cost?.totalAmount?.amount ?? cart?.totalAmount?.amount;\n setIfDefined(meta, 'cart_total', safeNumber(cartTotal));\n const itemCount = countLineItems(cart?.lines) ?? countLineItems(cart?.lineItems);\n setIfDefined(meta, 'item_count', itemCount);\n } else if (name === 'checkout_started') {\n const total = checkout?.totalPrice?.amount ?? checkout?.subtotalPrice?.amount;\n setIfDefined(meta, 'cart_total', safeNumber(total));\n setIfDefined(meta, 'currency', safeString(checkout?.currencyCode ?? checkout?.totalPrice?.currencyCode));\n const itemCount = countLineItems(checkout?.lineItems) ?? countLineItems(event.data?.checkoutLineItems);\n setIfDefined(meta, 'item_count', itemCount);\n } else {\n setIfDefined(meta, 'currency', safeString(checkout?.currencyCode ?? checkout?.totalPrice?.currencyCode));\n const total = checkout?.totalPrice?.amount;\n setIfDefined(meta, 'cart_total', safeNumber(total));\n }\n\n return meta;\n}\n\nfunction countLineItems(items: { quantity?: number }[] | undefined): number | undefined {\n if (!items || items.length === 0) return undefined;\n let count = 0;\n let hasNumericQuantity = false;\n for (const item of items) {\n const q = safeNumber(item.quantity);\n if (q !== undefined) {\n hasNumericQuantity = true;\n count += q;\n }\n }\n // Fallback to row count only when no line had a numeric quantity. An explicit\n // sum of zero (e.g. `[{ quantity: 0 }]`) must be preserved, not replaced with\n // `items.length`, otherwise empty carts over-report `item_count`.\n return hasNumericQuantity ? count : items.length;\n}\n\n// Caps merchant-controlled free-text to keep payloads under the API's per-string DTO limit.\n// IDs, tokens, and currency codes are naturally bounded by Shopify and skip capping.\nconst MAX_TEXT_LEN = 255;\nconst MAX_SKU_LEN = 128;\n\n// Mirrors `src/constants/config.constants.ts` `XSS_PATTERNS`. Inlined because\n// the constants module pulls in unrelated dependencies that would blow the\n// pixel bundle's 5KB budget. Defense-in-depth: Shopify product titles/SKUs/\n// vendors are merchant-controlled; a compromised admin could inject HTML/JS\n// that lands in dashboards or logs.\nconst XSS_PATTERNS: readonly RegExp[] = [\n /<script\\b[^<]*(?:(?!<\\/script>)<[^<]*)*<\\/script>/gi,\n /javascript:/gi,\n /on\\w+\\s*=/gi,\n /<iframe\\b[^<]*(?:(?!<\\/iframe>)<[^<]*)*<\\/iframe>/gi,\n /<embed\\b[^>]*>/gi,\n /<object\\b[^<]*(?:(?!<\\/object>)<[^<]*)*<\\/object>/gi,\n];\n\nfunction sanitize(value: string | undefined): string | undefined {\n if (value === undefined) return undefined;\n let out = value;\n for (const pattern of XSS_PATTERNS) out = out.replace(pattern, '');\n return out;\n}\n\nfunction cap(value: string | undefined, max: number): string | undefined {\n if (value === undefined) return undefined;\n return value.length > max ? value.slice(0, max) : value;\n}\n\nfunction mapLineItems(items: ShopifyLineItem[] | undefined): Record<string, unknown>[] {\n if (!items || items.length === 0) return [];\n const capped = items.slice(0, MAX_LINE_ITEMS);\n return capped.map((item) => {\n const out: Record<string, unknown> = {};\n const variantId = item.variant?.id;\n if (typeof variantId === 'string' || typeof variantId === 'number') {\n out['id'] = String(variantId);\n }\n setIfDefined(out, 'title', cap(sanitize(safeString(item.title ?? item.variant?.product?.title)), MAX_TEXT_LEN));\n setIfDefined(out, 'quantity', safeNumber(item.quantity));\n setIfDefined(out, 'price', safeNumber(item.variant?.price?.amount ?? item.finalLinePrice?.amount));\n setIfDefined(out, 'sku', cap(sanitize(safeString(item.variant?.sku ?? undefined)), MAX_SKU_LEN));\n setIfDefined(out, 'vendor', cap(sanitize(safeString(item.variant?.product?.vendor)), MAX_TEXT_LEN));\n return out;\n });\n}\n\nfunction generateEventId(event: ShopifyEvent, ts: number): string {\n const suffix = safeString(event.id)?.slice(-6) ?? Math.random().toString(36).slice(2, 8);\n return `${ts}-001-${suffix}`;\n}\n\nfunction resolveTimestamp(value: string | undefined): number {\n if (typeof value !== 'string' || value.length === 0) return Date.now();\n const parsed = Date.parse(value);\n return Number.isFinite(parsed) ? parsed : Date.now();\n}\n\nfunction stripUrlParams(href: string): string {\n // Pixel runs in the checkout sandbox where href can carry recovery tokens\n // (`?recovery=...`, `?key=...`). Path alone identifies the funnel stage; drop\n // query and hash to keep tokens out of telemetry. Mirrors the policy in\n // `src/utils/network/url.utils.ts` (which is too heavy to import into the\n // sub-5KB pixel bundle).\n const queryIdx = href.indexOf('?');\n const hashIdx = href.indexOf('#');\n let cutoff = href.length;\n if (queryIdx !== -1) cutoff = queryIdx;\n if (hashIdx !== -1 && hashIdx < cutoff) cutoff = hashIdx;\n return href.slice(0, cutoff);\n}\n\nfunction resolvePageUrl(event: ShopifyEvent): string {\n const href = safeString(event.context?.window?.location?.href) ?? safeString(event.context?.document?.location?.href);\n return href !== undefined ? stripUrlParams(href) : 'unknown';\n}\n\nexport function mapEventToBody(\n shopifyEvent: ShopifyEvent | null | undefined,\n shopifyEventName: ShopifyEventName,\n): PixelEventBody | null {\n if (!shopifyEvent) return null;\n\n const checkoutAttrs = shopifyEvent.data?.checkout?.attributes;\n const cartAttrs = shopifyEvent.data?.cart?.attributes;\n\n const sessionId = attrLookup(checkoutAttrs, 'tracelog_session_id') ?? attrLookup(cartAttrs, 'tracelog_session_id');\n if (sessionId === null) return null;\n\n // Identity stitching: prefer `tracelog_user_id` written by `ShopifyCartLinker`\n // (lib opt-in via `integrations.tracelog.shopify: true`). Fall back to Shopify's\n // per-visitor `clientId` so legacy storefronts that only write `tracelog_session_id`\n // (manual inline script pattern, no public `getUserId()` available) still emit\n // funnel events instead of silently no-op'ing. Trade-off: with the fallback,\n // pre-checkout (storefront) and pixel events have different `user_id` values,\n // splitting one buyer into two TraceLog users. Merchants on `shopify: true` keep\n // full stitching.\n const userId =\n attrLookup(checkoutAttrs, 'tracelog_user_id') ??\n attrLookup(cartAttrs, 'tracelog_user_id') ??\n safeString(shopifyEvent.clientId);\n if (userId === undefined) return null;\n\n const ts = resolveTimestamp(shopifyEvent.timestamp);\n const eventId = generateEventId(shopifyEvent, ts);\n const metadata = buildMetadata(shopifyEvent, shopifyEventName);\n\n return {\n user_id: userId,\n session_id: sessionId,\n device: { type: 'unknown', os: 'unknown', browser: 'unknown' },\n events: [\n {\n id: eventId,\n type: 'custom',\n page_url: resolvePageUrl(shopifyEvent),\n timestamp: ts,\n custom_event: {\n name: `shopify_${shopifyEventName}`,\n metadata,\n },\n },\n ],\n _metadata: {\n client_version: PIXEL_CLIENT_VERSION,\n timestamp: ts,\n },\n };\n}\n","/**\n * Standalone HTTP sender for the Shopify Web Pixel Extension bundle.\n *\n * Posts to the path-based ingress (`ingest.tracelog.io/p/<id>/collect`) — NOT\n * `api.tracelog.io/events/collect` — because the middleware has the only CORS\n * handler that accepts `Origin: null` from sandboxed iframes.\n *\n * Best-effort: failures are silently swallowed. The webhook (Task 03) carries\n * the revenue contract; pixel events are funnel-only and accept ~5-30% loss.\n */\n\nconst INGEST_HOST = 'https://ingest.tracelog.io';\n\nexport interface PixelSenderSettings {\n /** TraceLog project identifier (e.g. `t756edc0pnn17ha7`). Provided by Shopify init payload. */\n projectId: string;\n}\n\nexport interface PixelEventBody {\n user_id: string;\n session_id: string;\n device: { type: string; os: string; browser: string };\n events: Array<{\n id: string;\n type: 'custom';\n page_url: string;\n timestamp: number;\n custom_event: { name: string; metadata: Record<string, unknown> };\n }>;\n _metadata: { client_version: string; timestamp: number };\n}\n\nexport function sendBatch(settings: PixelSenderSettings, body: PixelEventBody): void {\n // Trust boundary is the Shopify extension settings form, but a misconfigured\n // (empty) projectId would yield `https://ingest.tracelog.io/p//collect` and\n // 404 every event. Drop silently so it can be diagnosed via Shopify pixel logs.\n if (!settings.projectId) return;\n\n // Encode in case the merchant pastes whitespace, slashes, or other unsafe\n // characters into the Shopify extension settings form.\n const url = `${INGEST_HOST}/p/${encodeURIComponent(settings.projectId)}/collect`;\n try {\n void fetch(url, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n keepalive: true,\n body: JSON.stringify(body),\n }).catch(() => {});\n } catch {\n // Pixel runs inside Shopify's strict sandbox; fetch() may be unavailable\n // or throw synchronously. Funnel events are best-effort — drop silently.\n }\n}\n","import { mapEventToBody, type ShopifyEventName } from './event-mapper';\nimport { sendBatch, type PixelSenderSettings } from './pixel-sender';\n\ninterface ShopifyAnalyticsApi {\n subscribe: (event: ShopifyEventName, callback: (event: unknown) => void) => void;\n}\n\ninterface ShopifyPixelApi {\n analytics: ShopifyAnalyticsApi;\n}\n\n/**\n * Registers the TraceLog Shopify Web Pixel: subscribes to the 7 standard\n * Customer Events and forwards each one to `ingest.tracelog.io/p/<id>/collect`.\n *\n * Each subscription is declared explicitly because Shopify's static analyzer\n * inspects the bundle for `analytics.subscribe('event_name', ...)` calls;\n * loop-based subscriptions are flagged and the pixel is treated as inactive.\n */\nexport function registerShopifyPixel(api: ShopifyPixelApi, settings: PixelSenderSettings): void {\n const handle = (eventName: ShopifyEventName, payload: unknown): void => {\n const body = mapEventToBody(payload as Parameters<typeof mapEventToBody>[0], eventName);\n if (!body) return;\n sendBatch(settings, body);\n };\n\n api.analytics.subscribe('cart_viewed', (event) => {\n handle('cart_viewed', event);\n });\n api.analytics.subscribe('checkout_started', (event) => {\n handle('checkout_started', event);\n });\n api.analytics.subscribe('checkout_contact_info_submitted', (event) => {\n handle('checkout_contact_info_submitted', event);\n });\n api.analytics.subscribe('checkout_address_info_submitted', (event) => {\n handle('checkout_address_info_submitted', event);\n });\n api.analytics.subscribe('checkout_shipping_info_submitted', (event) => {\n handle('checkout_shipping_info_submitted', event);\n });\n api.analytics.subscribe('payment_info_submitted', (event) => {\n handle('payment_info_submitted', event);\n });\n api.analytics.subscribe('checkout_completed', (event) => {\n handle('checkout_completed', event);\n });\n}\n"],"names":["SHOPIFY_EVENTS","SHOPIFY_PIXEL_EVENT_NAMES","map","name","attrLookup","attrs","key","Array","isArray","a","value","length","safeNumber","Number","isFinite","safeString","setIfDefined","target","buildMetadata","event","checkout","data","cart","meta","clientId","checkoutToken","token","totalPrice","amount","subtotalPrice","currencyCode","orderId","order","id","String","rawItems","lineItems","items","capped","slice","item","out","variantId","variant","cap","sanitize","title","product","MAX_TEXT_LEN","quantity","price","finalLinePrice","sku","MAX_SKU_LEN","vendor","mapLineItems","cost","totalAmount","countLineItems","lines","checkoutLineItems","total","count","hasNumericQuantity","q","XSS_PATTERNS","pattern","replace","max","resolvePageUrl","href","context","window","location","document","queryIdx","indexOf","hashIdx","cutoff","stripUrlParams","mapEventToBody","shopifyEvent","shopifyEventName","checkoutAttrs","attributes","cartAttrs","sessionId","userId","ts","Date","now","parsed","parse","resolveTimestamp","timestamp","eventId","Math","random","toString","generateEventId","metadata","user_id","session_id","device","type","os","browser","events","page_url","custom_event","_metadata","client_version","sendBatch","settings","body","projectId","url","encodeURIComponent","fetch","method","headers","keepalive","JSON","stringify","catch","api","handle","eventName","payload","analytics","subscribe"],"mappings":"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"}
|