Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatic sticky bucketing for logged out users if ID is unset #145

Open
Develliot opened this issue Jan 27, 2022 · 11 comments
Open

Automatic sticky bucketing for logged out users if ID is unset #145

Develliot opened this issue Jan 27, 2022 · 11 comments

Comments

@Develliot
Copy link

Develliot commented Jan 27, 2022

How do you handle bucketing for users that are logged out and keep them sticky so they don't see a different variation on each page load?

Optimizely doesn't work unless submit a user id when setting up the provider.

I have an oauth 2.0 pkce login flow so I don't know about a logged in user until the client has access to window.
I'm currently assigning them a random ID, if I have nothing in user state or local storage and saving it to local storage but that is multiple re-renders when using a provider the wraps the entire app on the first visit. it's really nasty.

It would be nice if when no user id was provided Optimizely just handled it on client initialisation with ip/user agent hash or something. So for useDecision stuff for Optimizely full stack can do what Optimizely web can do without user IDs, I can't do this sort of stuff from the client. I could handle a single re-render on log in. This happens automatically for Optimizely web and ironically Optimizely full stack when I'm using flags it doesn't even though it costs a lot more money.

@Develliot Develliot changed the title Logged out user ids Automatic bucketing for logged out users Jan 27, 2022
@Develliot Develliot changed the title Automatic bucketing for logged out users Automatic sticky bucketing for logged out users Jan 27, 2022
@Develliot Develliot changed the title Automatic sticky bucketing for logged out users Automatic sticky bucketing for logged out users if ID is unset Jan 27, 2022
@oMatej
Copy link

oMatej commented Jan 27, 2022

The suggested option with IP / user agent does not sound like valid / achievable option, because during SSR it is not possible to determine such values from the react components tree.

How do you handle bucketing for users that are logged out and keep them sticky so they don't see a different variation on each page load?

You can create a cookie with a random unique value, that will stay in the customer browser for years (i.e. optimizelyEndUserId so that both WEB and FullStack can use the same user ID). You can use IP or user agent as you suggested, that will be provided to react by your Node.js server during SSR, or you can use any other approach that suit your needs.

I have an oauth 2.0 pkce login flow so I don't know about a logged in user until the client has access to window.
I'm currently assigning them a random ID, if I have nothing in user state or local storage and saving it to local storage but that is multiple re-renders when using a provider the wraps the entire app on the first visit. it's really nasty.

This sound like an issue with the way you integrated your application with Optimizely react-sdk, because it should not happen. Could you share some code related to Optimizely integration with your project?

@Develliot
Copy link
Author

Develliot commented Jan 28, 2022

This wraps my main app components

import { FC } from "react";
import { OptimizelyProvider, ReactSDKClient } from "@optimizely/react-sdk";
import { getOptimizelyInstance } from "src/services/optimizely";
import { useFundraiserContext } from "src/contexts/FundraiserContext";
import { isBrowser } from "src/utils/browserUtils";
import { useLocalStorage } from "src/hooks/useLocalStorage";
import useLayoutEffectBrowser from "src/hooks/useLayoutEffectBrowser";

// not the most secure but this ID generator is only for optimizely
const uuidv4 = (): string => {
  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
    var r = (Math.random() * 16) | 0,
      v = c == "x" ? r : (r & 0x3) | 0x8;
    return v.toString(16);
  });
};

export const OptimizelyWrapper: FC<{}> = ({ children }) => {
  // this is just createInstance() with SDK key pumped in
  const client: ReactSDKClient = getOptimizelyInstance();
  const [fundraiserState] = useFundraiserContext();
  const { fundraiser } = fundraiserState;

  // first prop is the key name and the second is the default before window is ready
  // in the experiments I filter out anyone with the user id of "logged_out"
  const [optimizelyId, setOptimizelyId] = useLocalStorage(
    "optimizely",
    "logged_out"
  );

 // only runs when there is window not run SSR
  useLayoutEffectBrowser(() => {
    if (optimizelyId === "logged_out" && !fundraiser?.uniqueId)
      setOptimizelyId(uuidv4());
  }, []);

 // use logged in user id else logged out user id
  const userID = fundraiser?.uniqueId || optimizelyId;

  // I wish I didn't have to check for optimizely client, +1 for the PR by fabb that fixes this
  // probably causes a re-render  
  return (
    <>
      {client ? (
        <OptimizelyProvider
          optimizely={client}
          user={{
            id: userID,
            attributes: {
              user_id: userID,
              is_logged_in: !!fundraiser?.uniqueId,
            },
          }}
          isServerSide={!isBrowser}
        >
          {children}
        </OptimizelyProvider>
      ) : (
        <>{children}</>
      )}
    </>
  );
};

export default OptimizelyWrapper;

@oMatej
Copy link

oMatej commented Jan 28, 2022

@Develliot since you are using SSR (next.js?), you should not use local storage, because it is not accessible on the server and you will have different output during SSR and CSR which might break hydration process. You should consider switching to cookies. You can take a look at this repository: https://github.com/oMatej/nextjs-optimizely.

It has the issue I described here: #87, but there is a chance it got fixed in recent updates of the SDK. I also described there potential workaround, but I am not sure if this still is a thing with the latest version of @optimizely/react-sdk.

What it does:

  • Allows to inject @optimizely/react-sdk only on specific set of pages (it should also work with splitting the library to separate chunk) that have experiments
  • Fetches the datafile during SSR so that Optimizely can generate proper HTML
  • Generate or reuse previous user id from the cookies¹
  • Passes the datafile and id through getServerSideProps to the client
  • Uses these values to set up new optimizely client instance in the browser

With that in place there should be no re-renders. Re-renders should happen only when the user object changes, and this should happen only as a response to user interactions with the page (if we want to activate some experiment based on it).

1. As for user ID, it is up to you how you want to handle that. You can use two different sources:

  • For not logged in customers you can generate UUID so that the experience will be consistent per browser.
  • For logged in customers you can use the account ID so that the experience will be consistent per account, regardless of the browser.

@Develliot
Copy link
Author

Thanks @oMatej I will have look at implementing cookies.

How important is datafile? When using things like useDecision its user/bucket specific, the the datafile from server looks like more general info for feature switching, could it be be blank and would useDecision still work?

With regards to user ID I'm pretty much generating it as you describe. The trick it going to get that stuff accessible server side.

@oMatej
Copy link

oMatej commented Jan 31, 2022

How important is datafile? When using things like useDecision its user/bucket specific, the the datafile from server looks like more general info for feature switching, could it be be blank and would useDecision still work?

The datafile contain all the data related to Optimizely experiments / features you configure in the dashboard, so it is kinda crucial. If your application support SSR then you need to fetch it on your own according to this: https://github.com/optimizely/react-sdk#server-side-rendering.

Providing sdkKey during initialisation of Optimizely instance would fetch the datafile for you, but it will not work during SSR (so it would cause different content rendered on the server, and then after rendering in the browser it would change causing flickering for the users which is not desired).

@Develliot
Copy link
Author

Develliot commented Feb 21, 2022

I if the datafile is crucial why does the async loading example on the main readme doesn't not even bother with it.

Unfortunately the user ID from cookies back into the response request isn't going to work in my use case because that is going to screw up my caching strategy. All pages have a logged in and logged out view, logged in data happens async and for most people viewing the pages they will see the highly cached logged out view. If I start passing cookies down with the request it is going to break caching.

I just want to be able to update the user id async and only re-render if the use decision says it should post hydration.

Other wise i don't want it to re-render

@Develliot
Copy link
Author

Why do I even have to instantiate a client why can't I just pass my SDK key into the provider and let the provider handle everything?

@oMatej
Copy link

oMatej commented Feb 21, 2022

I if the datafile is crucial why does the async loading example on the main readme doesn't not even bother with it.

Of course it does. It just fetch the datafile asynchronously based on the provided sdkKey. When the application does not support SSR, it initially render just a blank page, so Optimizely can prevent rendering this or another component untill it finish fetching datafile (or the timeout will be triggered). You do not have flickering of two variations in such a case, because it can just wait with rendering the element of the page until it has the information about selected variant.

Why do I even have to instantiate a client why can't I just pass my SDK key into the provider and let the provider handle everything?

During SSR your goal should be to render exactly the same content on the server, and in the browser when the hydration process is happening. That is why react logs errors when this is not happening.

You can initialize Optimizely only on the client side. However, it might cause errors with react hydration process, and it will not be the nicest user experience because i.e. the button will be rendered with orange color on the server and on the initial load in the customer browser, and then, X ms later, it will switch color to green because Optimizely got initialized asynchronously in the browser and the customer got assigned to the experiment.

It is the same thing as doing SSR with i.e. redux or any other state management library. If you want to prefill the store with some data, you need to gather them before sending the HTML to the client.

@Develliot
Copy link
Author

Develliot commented Mar 1, 2022

I think my first problem is that the Provider component is completely useless in my use case because it is so rigid I wish the provider looked more like this, so I can get it and set it with a hook from anywhere in my app with useOptimizelyContext():

import React, {
  FC,
  useState,
  createContext,
  useContext,
  Dispatch,
  SetStateAction,
} from "react";

import { ReactSDKClient } from "@optimizely/react-sdk";

export type OptimizelyContextStateType = {
  optimizely: ReactSDKClient | null;
  isServerSide: boolean;
  timeout: number | undefined;
};

const defaultState: OptimizelyContextStateType = {
  optimizely: null,
  isServerSide: false,
  timeout: 0,
};

export type OptimizelyContextProviderType = [
  OptimizelyContextStateType,
  Dispatch<SetStateAction<OptimizelyContextStateType>>
];

export const OptimizelyContext = createContext<OptimizelyContextProviderType>([
  { ...defaultState },
  () => {},
]);

export const useOptimizelyContext = () => useContext(OptimizelyContext);

export const OptimizelyContextProvider: FC = ({ children }) => {
  const [state, setState] = useState({
    ...defaultState,
  });

  return (
    <OptimizelyContext.Provider value={[state, setState]}>
      {children}
    </OptimizelyContext.Provider>
  );
};

No user ID stuff at all, and see the setter is exposed

Then you can choose how you set the user for you could do directly in the component so it renders SSR or I could do something like this to it works client side.

export const OptimizelyLoader: FC<{}> = ({ children }) => {
  const { trackError } = useTrackingContext();
  const [fundraiserState] = useFundraiserContext();
  const { fundraiser } = fundraiserState;

  const [optimizelyData, setOptimizelyData] = useOptimizelyContext();
  const { optimizely } = optimizelyData;


  // "logged_out" is default effectively user_id unset
  const [optimizelyId, setOptimizelyId] = useLocalStorage(
    "optimizely",
    "logged_out"
  );

  const uuidv4 = (): string => {
    return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
      var r = (Math.random() * 16) | 0,
        v = c == "x" ? r : (r & 0x3) | 0x8;
      return v.toString(16);
    });
  };

  const getClientInstance = async () => {
    try {
      const resp = await fetch(
        `https://cdn.optimizely.com/datafiles/${OPTIMIZELY_SDK_KEY}.json`
      );
      const datafile = await resp.json();
      const instance = createInstance({
        datafile: datafile,
      });
      return instance;
    } catch (err) {
      trackError(err as Error);
      return null;
    }
  };

  useLayoutEffectBrowser(() => {
    if (!OPTIMIZELY_SDK_KEY.length) return;

    if (optimizelyId === "logged_out" && !fundraiser?.uniqueId) {
      setOptimizelyId(uuidv4());
    }
    getClientInstance().then((client: ReactSDKClient | null) => {
      if (!!client) {
        setOptimizelyData({
          ...optimizelyData,
          optimizely: client,
        });
      }
    });
  }, []);

  useEffect(() => {
    const userID = fundraiser?.uniqueId || optimizelyId;

    if (userID && optimizely) {
      const userData = {
        id: userID,
        attributes: {
          user_id: userID,
          is_logged_in: !!fundraiser?.uniqueId,
        },
      };

      optimizely.setUser(userData);
    }
  }, [optimizely, optimizelyId, fundraiser]);

  return <></>;
};

export default OptimizelyLoader;

If the useDecision hook checked for optimizely client on the provider and returned an error object similar to useSWR instead of throwing an error or responded with "default" or "fallback" then my app could handle that gracefully.

I can update the provider with user ID when I'm ready I can render the provider SSR and sort out the optimizely client when I know it isn't going to throw errors, and the hooks will update when they can based on changes in the provider and fail gracefully if getClient fails.

@mikechu-optimizely
Copy link
Contributor

Hello All,

I'm working to clean up the latent GitHub Issues for the React and Javascript repos. I know we're way behind here and thanks for handing in there with us as we optimize our processes.

After reading through this one. I wonder if the Advance Audience Targeting's (AAT) VUID would work to accomplish sticky bucketing for a logged out user.

@mikechu-optimizely
Copy link
Contributor

We released v3 of the React SDK allowing for anonymous instantiation of the optimizely client without a user.id in client-side contexts. Versions > 3.0.1 will have this new paradigm ironed out.

linked to internal ticket FSSDK-9985

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants