Hey all, I'm back with yet more integration and SR...
# ask-questions
l
Hey all, I'm back with yet more integration and SRM problems. We're trying to do split URL testing but it's proving to be not as simple as "just look at the repo". Running on Next.js, I'm trying to handle all of this Growthbook stuff in middleware so we can handling the bucketing and the redirection before the client renders to avoid flashing. I'm using
@growthbook/growthbook
Node SDK alongside Segment to make a node call when the middleware loads. It fetches or creates an ID (at this point matching with Segment's anonymous ID), then sets up Growthbook, gets the features/experiments, and then performs the redirect. However I'm only getting about half of the traffic redirecting to the other URL. Code in ๐Ÿงต
Copy code
import { GrowthBook } from "@growthbook/growthbook";
import { configureServerSideGrowthBook } from "@lib/growthbook-server";
import { Analytics } from "@segment/analytics-node";
import { NextRequest, NextResponse } from "next/server";

export async function handleGrowthBookExperiments(
  request: NextRequest,
  stableId: string,
  analytics: Analytics,
): Promise<NextResponse | null> {
  configureServerSideGrowthBook();

  const gb = new GrowthBook({
    clientKey: process.env.GROWTHBOOK_CLIENT_KEY,
    enableDevMode: true,
  });

  await gb.init({ timeout: 1000 });

  await gb.setAttributes({
    id: stableId,
    url: request.url,
    query: request.nextUrl.search,
    host: request.nextUrl.hostname,
    path: request.nextUrl.pathname,
  });

  if (["/features", "/features-overview"].includes(request.nextUrl.pathname)) {
    const featureFlagId = "features-overview-redirect";

    const variation = gb.getFeatureValue(featureFlagId, false);

    if (request.nextUrl.pathname === "/features" && variation) {
      const url = request.nextUrl.clone();
      url.pathname = "/features-overview";
      gb.destroy();
      return NextResponse.redirect(url, {
        status: 302,
      });
    }

    analytics.track({
      anonymousId: stableId,
      event: "Experiment Viewed",
      properties: {
        experimentId: "features-overview-redirect-test",
        variationId: Number(variation).toString(),
      },
    } as any);
  }

  gb.destroy();
  return null;
}
I'm not really sure what I'm missing here. I swear I've read every ounce of the documentation five times over ๐Ÿ˜…
Here's how it looks in middleware (a bit summarized)
Copy code
import { Analytics } from "@segment/analytics-node";
import { getStableId } from "@utils/get-stable-id";
import { handleGrowthBookExperiments } from "@utils/middleware/growthbook";
import { NextRequest, NextResponse } from "next/server";

export default async function middleware(request: NextRequest) {
  let response = NextResponse.next();

  const analytics = new Analytics({
    writeKey: process.env.NEXT_PUBLIC_SEGMENT_WRITE_KEY,
  });

  // Get unified stable ID that consolidates all ID systems
  const { value: stableId, isFresh } = await getStableId();

  if (isFresh || !request.cookies.get("ajs_anonymous_id")) {
    response.cookies.set("ajs_anonymous_id", stableId);
  }

  // Use unified stable ID for GrowthBook UUID (backward compatibility)
  if (isFresh || !request.cookies.get("gbuuid")) {
    response.cookies.set({
      name: "gbuuid",
      value: stableId,
      httpOnly: false,
      secure: true,
      path: "/",
      expires: new Date(Date.now() + 400 * 24 * 60 * 60 * 1000),
    });
  }

  // Process GrowthBook experiments
  const experimentResponse = await handleGrowthBookExperiments(
    request,
    stableId,
    analytics,
  );
  if (experimentResponse) {
    return experimentResponse;
  }

  return response;
}

export const config = {
  matcher: "/((?!api|static|.*\\..*|_next).*)",
};
then how we're doing IDs:
Copy code
import { cookies, headers } from "next/headers";
import { dedupe } from "flags/next";
import { customAlphabet } from "nanoid";

// Use the same custom alphabet as current middleware for backward compatibility
const customNanoid = customAlphabet('123456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz', 24);

/**
 * Unified stable ID that consolidates all existing ID systems
 * Priority order: header > ajs_anonymous_id > gbuuid > new ID
 */
export const getStableId = dedupe(async () => {
  const cookiesStore = await cookies();
  const header = await headers();

  // 2. Check Segment anonymous ID (backward compatibility)
  const segmentId = cookiesStore.get("ajs_anonymous_id")?.value;
  if (segmentId) {
    return { value: segmentId, isFresh: false };
  }

  // 3. Check GrowthBook UUID (backward compatibility)
  const gbId = cookiesStore.get("gbuuid")?.value;
  if (gbId) {
    return { value: gbId, isFresh: false };
  }

  // 4. Generate new unified ID using same format as existing systems
  return { value: customNanoid(), isFresh: true };
});
I will say that our clientside React setup is working just fine with reasonable splitting across the board.
I've looked at the Next.js example but we're not using Flags...is that a requirement?
s
Glad you're making progress on this, but sorry you're still having trouble. Is it the case that a user will always land on
/features
and then either continue to that URL or be redirected to
/features-overview
depending on the experiment result? Isn't the expected result that half of the traffic would redirected and half not? Or what am I missing? Oh, and Flags isn't a requirement.
One thing I notice is that your analytics call seems to happen after you redirect. That could explain discrepancies in tracking numbers.
l
Okay I ended up spending a lot of time on this this last week and I've got a solution that seems to work overall. There was actually a UUID matching issue and passing that information on through Next.js before a user had actually loaded the browser was harder than I thought. This is what it looks like now:
Copy code
export default async function middleware(request: NextRequest) {

  const headers = new Headers(request.headers);

  // Get unified stable ID that consolidates all ID systems
  const { value: stableId } = await getStableId();

  let response = NextResponse.next();
  response.headers.set("x-generated-stable-id", stableId);

  const urlPath = new URL(request.url);

  const analytics = new Analytics({
    writeKey: process.env.NEXT_PUBLIC_SEGMENT_WRITE_KEY,
  });

  if (!request.cookies.get("ajs_anonymous_id")) {
    response.cookies.set({
      name: "ajs_anonymous_id",
      value: stableId,
      httpOnly: false,
      secure: true,
      path: "/",
      expires: new Date(Date.now() + 400 * 24 * 60 * 60 * 1000),
    });
  }

  // Use unified stable ID for GrowthBook UUID (backward compatibility)
  if (!request.cookies.get("gbuuid")) {
    response.cookies.set({
      name: "gbuuid",
      value: stableId,
      httpOnly: false,
      secure: true,
      path: "/",
      expires: new Date(Date.now() + 400 * 24 * 60 * 60 * 1000),
    });
  }
...rest of middleware
Then for the stable ID:
Copy code
import { cookies, headers } from "next/headers";

/**
 * Unified stable ID that consolidates all existing ID systems
 * Priority order: header > ajs_anonymous_id > gbuuid > new ID
 */
export const getStableId = async () => {
  const cookiesStore = await cookies();
  const headersStore = await headers();

  // First we check for header from previous middleware execution (for redirects)
  // This might happen because of a split URL redirection where
  // the stable ID needs to be passed on, but the response cookies
  // may not be set or haven't been able to be set.
  // We pass this stable ID header through and then here we
  // check for that ID. If it exists, we can properly set up all
  // the Segment and growthbook cookies
  const headerStableId = headersStore.get("x-generated-stable-id");
  if (headerStableId) {
    return { value: headerStableId };
  }

  // If we make it this far it means the header was not set, so then
  // we check for the segment ID which is stored as a cookie
  const segmentId = cookiesStore.get("ajs_anonymous_id")?.value;
  if (segmentId) {
    return { value: segmentId };
  }

  // If we make it this far it means neither the header nor the segment ID was set as a cookie, so then
  // we check for the growthbook ID which is stored as a cookie
  const gbId = cookiesStore.get("gbuuid")?.value;
  if (gbId) {
    return { value: gbId };
  }

  // Of course if there is no header, no Segment anon ID, or
  // a GB UUID, we generate a new one

  return { value: crypto.randomUUID() };
};
This sets the Segment anon ID and the GB UUID as the same (which as I understand isn't totally necessary, but that's how it's going), and now if there is a redirect we have to pass in the response with the new stable ID header in order to pass that on to the redirected URL
Copy code
import { GrowthBook } from "@growthbook/growthbook";
import { configureServerSideGrowthBook } from "@lib/growthbook-server";
import { Analytics } from "@segment/analytics-node";
import { NextRequest, NextResponse } from "next/server";

export async function handleGrowthBookExperiments(
  request: NextRequest,
  stableId: string,
  analytics: Analytics,
  response: NextResponse,
): Promise<NextResponse | null> {
  configureServerSideGrowthBook();

  const gb = new GrowthBook({
    clientKey: process.env.GROWTHBOOK_CLIENT_KEY,
    enableDevMode: true,
  });

  await gb.init({ timeout: 1000 });

  await gb.setAttributes({
    id: stableId,
    url: request.url,
    query: request.nextUrl.search,
    host: request.nextUrl.hostname,
    path: request.nextUrl.pathname,
  });

  if (["/features", "/features-overview"].includes(request.nextUrl.pathname)) {
    const featureFlagId = "test-feature-flag";

    const result = gb.evalFeature(featureFlagId);

    if (!result.experimentResult) {
      return null;
    }

    analytics.track({
      anonymousId: stableId,
      event: "Experiment Viewed",
      properties: {
        experimentId: "test-feature-experiment",
        variationId: result.experimentResult.variationId,
      },
    } as any);

    if (
      request.nextUrl.pathname === "/features" &&
      result.experimentResult.variationId === 1
    ) {
      const url = request.nextUrl.clone();
      url.pathname = "/features-overview";
      const newResponse = NextResponse.redirect(url, {
        status: 302,
        headers: response.headers,
      });

      return newResponse;
    } else if (
      request.nextUrl.pathname === "/features-overview" &&
      result.experimentResult.variationId === 0
    ) {
      const url = request.nextUrl.clone();
      url.pathname = "/";
      const newResponse = NextResponse.redirect(url, {
        status: 302,
        headers: response.headers,
      });

      return newResponse;
    }
  }

  gb.destroy();
  return null;
}
This honestly feels a bit overengineered but it seems to be working well enough. On a slightly lower traffic'd page we ran this for 5 days and got fairly consistent splits, though on the homepage over the last 24 hours it's a bit looser, and I'm not 100% sure why.
I swear I've poured over all of the docs multiple times and I can't figure out the best version of this.
I had even considered if bot traffic was a problem overall here but even still shouldn't there be an even split regardless?
I ran a quick report from the database:
Copy code
variation_id | unique_users | percentage
0	58883	49.40
1	60309	50.60
It's firing events pretty consistently but is obviously over-assigning to the variant.
Showing the test as unhealthy. This is so frustrating ๐Ÿ˜‚
s
Looking at the code, is it the case that you're bucketing twice for users in the variation? A user lands on
/features
, you track the exposure, then redirect them to the
/features-overview
where they're tracked again. Is that how the code's working? Can users land on
/features-overview
first? Or do they always go through
/features
first?
l
I have it set up so whenever you load either of those urls, you run
_const_ result = gb.evalFeature(featureFlagId);
, then based on the result of
result.experimentResult.variationId
it either keeps them on the page or redirects them. So if variationId is 0 but you're on
/features-overview
, it's redirecting, and vice versa. However the bucketing has already happened by that point.
My assumption is that based on their UUID, once we've set the attributes and then run
evalFeature
, then that's what's passed through to Segment regardless of the redirection logic. I checked the database and there's no user that has both variants logged to their ID.
Is it maybe because of where I'm initializing
gb
?
s
Maybe. Where's that code in the larger scheme of things? Also, I wasn't saying users would be bucketed in both variations, but that you'd be overcounting the variant 1. 1. User lands on `/features`n. and is in the variant: analytics fire. 2. Then, the user is redirected to
/features-overview
. Won't the analytics then fire again?
l
In theory yes, but if
evalFeature
is doing what it's supposed to, then the variation ID will be 1 in both instances, which is de-duped later anyways as a single user.
Ok so I made some changes to ensure that Growthbook wasn't running for bots visiting the pages, and also had noticed that the middleware was sometimes running several times per page load, which may have also forced Growthbook to lean heavier on the variant group. After these changes and adjusting the phase dates, it's much more in line:
Well I guess that was wrong