Skip to content

Commit

Permalink
feat: propagate additional products (#213)
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
sk- authored Nov 10, 2023
1 parent 9640ace commit 5fdb57b
Show file tree
Hide file tree
Showing 7 changed files with 124 additions and 9 deletions.
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div
class="product"
data-ts-resolved-bid="<resolvedBidId>"
>
...
</div>
```

and the following for organic products (which is optional)
```html
<div
class="product"
data-ts-product="<productId>"
data-ts-resolved-bid="<resolvedBidId>"
>
...
</div>
Expand All @@ -63,7 +71,7 @@ Additionally, in case not all the container is clickable (i.e., does not produce
</div>
```

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
<div
Expand All @@ -74,6 +82,17 @@ Finally, adding further information to purchases can be made by passing the `ts-
</div>
```

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
<div
class="product"
data-ts-product="<productId>"
data-ts-resolved-bid="inherit"
>
...
</div>
```
# E2E tests

Execute `npm run test:e2e`, at the end it will show you the url you need to visit to test the library.
Expand Down
14 changes: 12 additions & 2 deletions mocks/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? []) {
Expand Down
40 changes: 36 additions & 4 deletions src/detector.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
const bidStore = new BidStore("ts-b");

/**
* Generate an id.
Expand Down Expand Up @@ -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":
Expand All @@ -79,6 +88,7 @@ function getApiPayload(event: ProductEvent): TopsortEvent {
{
resolvedBidId: event.bid,
entity,
additionalAttribution,
placement,
occurredAt: t,
opaqueUserId: event.uid,
Expand All @@ -92,6 +102,7 @@ function getApiPayload(event: ProductEvent): TopsortEvent {
{
resolvedBidId: event.bid,
entity,
additionalAttribution,
placement,
occurredAt: t,
opaqueUserId: event.uid,
Expand Down Expand Up @@ -153,6 +164,7 @@ interface Purchase {
interface ProductEvent {
type: EventType;
product?: string;
additionalProduct?: string;
bid?: string;
t: number;
page: string;
Expand Down Expand Up @@ -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 {
Expand All @@ -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(),
Expand All @@ -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);
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface Entity {
interface Impression {
resolvedBidId?: string;
entity?: Entity;
additionalAttribution?: Entity;
placement: Placement;
occurredAt: string;
opaqueUserId: string;
Expand All @@ -19,6 +20,7 @@ interface Impression {
interface Click {
resolvedBidId?: string;
entity?: Entity;
additionalAttribution?: Entity;
placement: Placement;
occurredAt: string;
opaqueUserId: string;
Expand Down
24 changes: 24 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,27 @@ export class LocalStorageStore<T> implements Store<T> {
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) {}
}
}
21 changes: 21 additions & 0 deletions tests/browser-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface ProductEvent {
eventType?: string;
placement?: Placement;
entity?: Entity;
additionalAttribution?: Entity;
resolvedBidId?: string | null;
impressions?: Impression[];
items?: Purchase[];
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
7 changes: 7 additions & 0 deletions tests/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
<td>React Navigation</td>
<td id="test-react-navigation">Pending</td>
</tr>
<tr>
<td>Banner Products</td>
<td id="test-banner-products">Pending</td>
</tr>
<tr>
<td>No Errors</td>
<td id="test-no-errors">Pending</td>
Expand All @@ -77,6 +81,9 @@
<div data-ts-action="purchase" data-ts-items='[{"product": "product-id-purchase-1", "quantity":1, "price": 2399}, {"product": "product-id-purchase-2", "quantity": 2, "price": 399}]'>P</div>
<div id="old-product" data-ts-product="product-id-attr-impression-1">P</div>
<div id="hidden-product" data-ts-product="product-id-impression-hidden" style="visibility: none;">H</div>

<div id="banner" data-ts-resolved-bid="17785055-1111-4b4e-9fb0-5fc4cff0af3f">Banner</div>
<div id="banner-product" data-ts-product="additional-product-banner" data-ts-resolved-bid="inherit">P</div>
</div>
<div id="root"></div>
<script>
Expand Down

0 comments on commit 5fdb57b

Please sign in to comment.