🐛 [Shopify] Defer plugin init until a checkout page view, fixing double session IDs - #4981
🐛 [Shopify] Defer plugin init until a checkout page view, fixing double session IDs#4981lierniel wants to merge 8 commits into
Conversation
🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: 7b60dac | Docs | View more details | Give us feedback! |
Bundles Sizes Evolution
|
30045a3 to
4d662d6
Compare
There was a problem hiding this comment.
💬 suggestion: If we want to decouple the logic for the onInit and onStart hooks, I would create two separate functions. We could keep it relatively simple:
/**
* Calls each plugin's `onInit`, and returns whether the initialization should go on: `false` if any
* plugin aborts it. Stays synchronous as long as no plugin returns a thenable.
*/
export function callPluginsOnInit(
plugins: RumPlugin[] | undefined,
parameter: { initConfiguration: RumInitConfiguration; publicApi: RumPublicApi }
): boolean | Promise<boolean> {
const results = (plugins ?? []).map((plugin) => plugin.onInit?.(parameter))
if (results.some(isThenable)) {
return Promise.all(results.map((result) => Promise.resolve(result))).then(
(resolvedResults) => !resolvedResults.includes(false)
)
}
return !results.includes(false)
}
export function callPluginsOnRumStart(plugins: RumPlugin[] | undefined, options: OnRumStartOptions): void {
for (const plugin of plugins ?? []) {
plugin.onRumStart?.(options)
}
}| waitForThenable(Promise.resolve(result), DEFAULT_ON_INIT_TIMEOUT).catch((reason) => { | ||
| if (isTimeoutError(reason)) { | ||
| throw new Error(`Plugin ${plugins[index].name} onInit() timed out after ${DEFAULT_ON_INIT_TIMEOUT}ms`) | ||
| } | ||
| throw reason |
There was a problem hiding this comment.
Suggestion: keep things simple, don't handle timeouts.
There was a problem hiding this comment.
Do you mean just ignore them completely?
My motivation to handle it is to show some meaningful error message to the plugins consumers - so they will know which plugin has failed to init and why instead of generic Timeout error message.
Also I wound't say it adds a lot of complexity to ours code - just another .catch block, but let me know if you disagree
There was a problem hiding this comment.
Yes I would ignore it completely. If you really want a timeout, move it to the salesforce plugin.
The reason I am a bit reluctant is that is the main bundle size impact, with 0 benefit for the vast majority of usages.
| if (isBindingsInstalled) { | ||
| return | ||
| } |
There was a problem hiding this comment.
can you unsubscribe instead? This isBindingInstalled makes things more complex than necessary
There was a problem hiding this comment.
No, there is no option to unsubscribe from shopify events unfortunately :(
Here is official API reference for analytics.subscribe method and it's returning a Promise<undefined>, not an unsubscribe function or anything I can use later to detach the listener: https://shopify.dev/docs/api/web-pixels-api/standard-api/analytics
There was a problem hiding this comment.
Ok! Then nitpick: I would introduce a function like waitFirstPageViewedEvent(analytics) to isolate the subscription logic. You could even have an async onInit function:
async onInit({ initConfiguration, publicApi }) {
const analytics = configuration.shopifyAnalytics
if (!analytics) {
return false
}
const event = await waitFirstPageViewedEvent(analytics)
if (!isCheckoutPage(event)) {
return false
}
...
}
There was a problem hiding this comment.
Question: How about if in vuePlugin.ts (and the rest of the plugins for that matter), we do:
export type VuePlugin = Omit<Required<RumPlugin>, 'onInit'> & { // vuePlugin's onInit is synchronous, unlike the generic RumPlugin interface which allows an // async onInit. Narrowing the return type here avoids @typescript-eslint/no-floating-promises // false positives on every onInit call site. onInit(options: { initConfiguration: RumInitConfiguration; publicApi: RumPublicApi }): false | void }
This way, we can declare the onInit and make in sync and not have to use the eslint ignore on each call.
There was a problem hiding this comment.
Ngl, it looks like more effort to us to maintain it like you suggested, because we'll have to manually narrow types almost for every plugin we have - seems like a typing issue to me. But I agree that the current approach looks bad as well, so let's discuss
There was a problem hiding this comment.
I agree. There are tradeoffs with both ways. Let's discuss if we can find a cleaner alternative.
There was a problem hiding this comment.
Do you mind if we defer the discussion for another PR? I experimented a bit and didn't find the immediate answer, so to not block the Shopify integration, let's tackle it later. I'll create the dedicated ticket for it, so wdyt?
There was a problem hiding this comment.
Yeah sounds fair. I'll take another look tmr morning.
There was a problem hiding this comment.
As discussed, maybe a new optional parameter in RumPlugin since onInit is optional could be a good approach.
There was a problem hiding this comment.
Created a dedicated ticket here: https://datadoghq.atlassian.net/browse/RUM-18333
Motivation
Shopify Custom Pixel sandboxes were creating two RUM session IDs on the same page. The storefront's
Theme Liquid snippet already runs a
DD_RUMinstance for every page, while the Custom Pixel'sshopifyPluginunconditionally ran its owninit()side effects (patching sandboxed iframe APIs,wiring bindings, forcing
trackViewsManually, etc.) as soon asonInitfired — with no way to knowyet whether the page was actually a checkout page. See RFC: Preventing two SDK instances from
running at the same time
(RUM-18173).
Changes
RumPlugin.onInitcontract (packages/browser-rum-core/src/domain/plugins.ts,preStartRum.ts) soonInitmay returnfalseto abort SDK init, or aPromise<false | void>todefer it.
callPluginsMethod/runOnInitPluginsnow run plugins'onInitin order, stayingsynchronous until a plugin returns a thenable, and time out a pending
onInitafter 3s (surfacingan error rather than hanging init forever).
shopifyPlugin.onInitnow returns aPromisethat waits for the sandbox's firstpage_viewedevent and only proceeds (patches iframe APIs, wires bindings, forces sandbox-specific config) once
that event's URL matches a checkout path — so a Custom Pixel loaded on a non-checkout page no longer
spins up a second RUM instance.
initShopifyBindings'sclicked/ui_extension_erroredhandlers arenow gated the same way, via the shared
isCheckoutPagepredicate.makeShopifyRumPublicApi()init()-wrapping approach with the plugin-basedshopifyPlugin, now exposed asDD_RUM.shopifyPlugin(...)(see updatedpackages/browser-rum-shopify/README.md).Test instructions
yarn test:unit --spec packages/browser-rum-core/src/domain/plugins.spec.ts --spec packages/browser-rum-core/src/boot/preStartRum.spec.ts --spec packages/browser-core/src/tools/thenable.spec.ts --spec "packages/browser-rum-shopify/**/*.spec.ts"Checklist