From 5fdb57b3dc3cc12f7f2993260efb70ae0ded92b9 Mon Sep 17 00:00:00 2001 From: Sebastian Kreft Date: Fri, 10 Nov 2023 10:59:05 -0300 Subject: [PATCH] feat: propagate additional products (#213) In case customers want to attribute purchases to banners they need to specify a product and a resolved bid as inherit. In such a case we would grab the resolved id id that was stored in session storage in the previous click. There are some caveats with this approach: 1) If there's a mistake and an inherit resolved bid is used in a page which is not the target of a banner, then the events will fail. 2) It could happen that the user manually changes the url to a banner's destination page and in that case we would report the events to the wrong banner. 3) This won't always work if session storage is not available (support is 97.4% https://caniuse.com/mdn-api_window_sessionstorage). We have a fallback using an in memory cache, but that would only work if the page it's not reloaded (Vue, React, etc) --- README.md | 25 ++++++++++++++++++++++--- mocks/api-server.ts | 14 ++++++++++++-- src/detector.ts | 40 ++++++++++++++++++++++++++++++++++++---- src/events.ts | 2 ++ src/store.ts | 24 ++++++++++++++++++++++++ tests/browser-test.ts | 21 +++++++++++++++++++++ tests/test.html | 7 +++++++ 7 files changed, 124 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 99bfbf6..aea3dbf 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,21 @@ Either mix quotes (single/double) or escape certain characters inside your value const newvalue = currentvalue.replace('"', """).replace("'", "'"); // etc. ``` -Pass said values to your html: +Add the following markup to promoted products: +```html +
+ ... +
+``` +and the following for organic products (which is optional) ```html
...
@@ -63,7 +71,7 @@ Additionally, in case not all the container is clickable (i.e., does not produce ``` -Finally, adding further information to purchases can be made by passing the `ts-data-items` JSON array: +Adding further information to purchases can be made by passing the `ts-data-items` JSON array: ```html
``` +Finally, in case you are using banners and want to have further control on the attributable products you need to add the following markup in the banner's destination page. + +```html +
+ ... +
+``` # E2E tests Execute `npm run test:e2e`, at the end it will show you the url you need to visit to test the library. diff --git a/mocks/api-server.ts b/mocks/api-server.ts index 1ff34cb..2783c16 100644 --- a/mocks/api-server.ts +++ b/mocks/api-server.ts @@ -58,11 +58,21 @@ app.post("/:session/v2/events", (req, res) => { const payload = req.body; let totalEvents = 0; for (const event of payload.impressions ?? []) { - addEvent(event.entity.id, "impression", event, session); + addEvent( + event.entity?.id ?? event.additionalAttribution?.id, + "impression", + event, + session, + ); totalEvents++; } for (const event of payload.clicks ?? []) { - addEvent(event.entity.id, "click", event, session); + addEvent( + event.entity?.id ?? event.additionalAttribution?.id, + "click", + event, + session, + ); totalEvents++; } for (const event of payload.purchases ?? []) { diff --git a/src/detector.ts b/src/detector.ts index 5dd7e89..7342534 100644 --- a/src/detector.ts +++ b/src/detector.ts @@ -1,11 +1,13 @@ import { TopsortEvent, Entity } from "./events"; import { ProcessorResult, Queue } from "./queue"; import { reportEvent } from "./reporter"; +import { BidStore } from "./store"; const MAX_EVENTS_SIZE = 2500; // See https://support.google.com/admanager/answer/4524488?hl=en const INTERSECTION_THRESHOLD = 0.5; let seenEvents = new Set(); +const bidStore = new BidStore("ts-b"); /** * Generate an id. @@ -71,6 +73,13 @@ function getApiPayload(event: ProductEvent): TopsortEvent { id: event.product, }; } + let additionalAttribution: Entity | undefined = undefined; + if (event.additionalProduct) { + additionalAttribution = { + type: "product", + id: event.additionalProduct, + }; + } const t = new Date(event.t).toISOString(); switch (eventType) { case "Click": @@ -79,6 +88,7 @@ function getApiPayload(event: ProductEvent): TopsortEvent { { resolvedBidId: event.bid, entity, + additionalAttribution, placement, occurredAt: t, opaqueUserId: event.uid, @@ -92,6 +102,7 @@ function getApiPayload(event: ProductEvent): TopsortEvent { { resolvedBidId: event.bid, entity, + additionalAttribution, placement, occurredAt: t, opaqueUserId: event.uid, @@ -153,6 +164,7 @@ interface Purchase { interface ProductEvent { type: EventType; product?: string; + additionalProduct?: string; bid?: string; t: number; page: string; @@ -183,7 +195,12 @@ function logEvent(info: ProductEvent, node: Node) { } function getId(event: ProductEvent): string { - return [event.page, event.type, event.product, event.bid].join("-"); + return [ + event.page, + event.type, + event.product ?? event.additionalProduct, + event.bid, + ].join("-"); } function getPage(): string { @@ -195,11 +212,22 @@ function getPage(): string { } function getEvent(type: EventType, node: HTMLElement): ProductEvent { - const product = node.dataset.tsProduct; - const bid = node.dataset.tsResolvedBid; + let product = node.dataset.tsProduct; + let bid = node.dataset.tsResolvedBid; + let additionalProduct: string | undefined = undefined; + if ( + bid == "inherit" && + product && + (type == "Click" || type == "Impression") + ) { + bid = bidStore.get(); + additionalProduct = product; + product = undefined; + } const event: ProductEvent = { type, product, + additionalProduct, bid, t: Date.now(), page: getPage(), @@ -218,7 +246,11 @@ function interactionHandler(event: Event): void { } const container = event.currentTarget.closest(PRODUCT_SELECTOR); if (container && container instanceof HTMLElement) { - logEvent(getEvent("Click", container), container); + const interactionEvent = getEvent("Click", container); + logEvent(interactionEvent, container); + if (interactionEvent.bid) { + bidStore.set(interactionEvent.bid); + } } } diff --git a/src/events.ts b/src/events.ts index b537a29..73952b2 100644 --- a/src/events.ts +++ b/src/events.ts @@ -10,6 +10,7 @@ export interface Entity { interface Impression { resolvedBidId?: string; entity?: Entity; + additionalAttribution?: Entity; placement: Placement; occurredAt: string; opaqueUserId: string; @@ -19,6 +20,7 @@ interface Impression { interface Click { resolvedBidId?: string; entity?: Entity; + additionalAttribution?: Entity; placement: Placement; occurredAt: string; opaqueUserId: string; diff --git a/src/store.ts b/src/store.ts index ba347d4..23e5e13 100644 --- a/src/store.ts +++ b/src/store.ts @@ -31,3 +31,27 @@ export class LocalStorageStore implements Store { this._storage.setItem(this._key, JSON.stringify(data)); } } + +export class BidStore { + private _key: string; + private _storage: Storage; + private _bid: string | undefined; + constructor(key: string) { + this._key = key; + this._storage = window.sessionStorage; + this._bid = undefined; + } + get(): string | undefined { + try { + return this._storage.getItem(this._key) ?? undefined; + } catch (error) { + return this._bid; + } + } + set(bid: string): void { + this._bid = bid; + try { + this._storage.setItem(this._key, bid); + } catch (error) {} + } +} diff --git a/tests/browser-test.ts b/tests/browser-test.ts index c57c225..be60e8e 100644 --- a/tests/browser-test.ts +++ b/tests/browser-test.ts @@ -42,6 +42,7 @@ interface ProductEvent { eventType?: string; placement?: Placement; entity?: Entity; + additionalAttribution?: Entity; resolvedBidId?: string | null; impressions?: Impression[]; items?: Purchase[]; @@ -124,6 +125,12 @@ async function runTests() { const productArea = document.getElementById("click-area"); productArea?.click(); + // Click on banner and product + const banner = document.getElementById("banner"); + banner?.click(); + const bannerProduct = document.getElementById("banner-product"); + bannerProduct?.click(); + // Add new product const newProduct = document.createElement("div"); newProduct.dataset.tsProduct = "product-id-dyn-impression-1"; @@ -267,6 +274,20 @@ async function checkTests() { }), ); + await setTestResult( + "test-banner-products", + checkEventExists("additional-product-banner", "click", { + additionalAttribution: { + id: "additional-product-banner", + type: "product", + }, + placement: { + path: "/test.html", + }, + resolvedBidId: "17785055-1111-4b4e-9fb0-5fc4cff0af3f", + }), + ); + await setTestResult("test-no-errors", checkNoErrors()); console.info("Done checking results"); diff --git a/tests/test.html b/tests/test.html index dfe213d..b8366f3 100644 --- a/tests/test.html +++ b/tests/test.html @@ -64,6 +64,10 @@ React Navigation Pending + + Banner Products + Pending + No Errors Pending @@ -77,6 +81,9 @@
P
P
H
+ + +