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

Google Tag Manager - Consent Mode V2 implementation (Next.js 14) #427

Open
gaurangrshah opened this issue Mar 5, 2024 · 1 comment
Open

Comments

@gaurangrshah
Copy link

gaurangrshah commented Mar 5, 2024

Description

I am currently working on building out a consent manager feature and came across a few challenges integrating with the @analytics/google-tag-manager plugin. I've created some ("sketchy" - at best) workarounds which I am sharing below for feedback and suggestions on how to improve and especially on how to better incorporate this logic into the plugin itself.

Expected Behavior

I don't mind raising a P.R. and doing the work, as long as I have a clear path forward. Sorry for the long intro, but I'll break down the issue here:

  1. Consent Mode v2 requires that default consents get initialized before any tags are read. See here. This ideally would mean that we've disabled the plugin. Which in turn means the gtag script is not injected into the DOM.

  2. Currently there are no apis or mechanisms from what I can gather that enable us to tap into this event specifically: gtm.init_consent.
    This event can be triggered declaratively (demonstrated in my workaround below).

  3. No access to the actual script tag, no apis or a clean way of even referencing it in the DOM. Having a reference to the script itself opens up the option to set an event listener to fire when the script loads. Which is the hack that I came across.
    Moreover, no exposed access to the internal gtag fn that is used to handle the page and track events under the hood. Or even better direct access to the dataLayer object.

UPDATE: I did notice a few options in the plugin source code that might allow a better dx, but will need to explore and likely requires either extending the current plugin or creating a custom plugin to handle the initial consent. I've documented this below in my examples accordingly.

Feature Request

Ideally an imperative way to initialize default consent scopes directly via the plugin's userConfig params.

At the least, I'd like to add an #id attribute to the script tag element which would allow us to grab a reference to the element itself. I've tested this locally directly in my node modules and this definitely works.

Without access to the script itself and having trouble targeting it via the src attribute, I've cobbled together this workaround:

Additonal Context

  1. Grab a reference to the internal gtag function that gets set on the window when the plugin initializes.
export const gtagFn = (
  dataLayerName: DataLayerName,
  gtagName: GtagName
): ((..._args: any[]) => any) => {
  if (typeof window !== 'undefined') {
    return (
      // @ts-expect-error
      window[gtagName] ||
      // @ts-expect-error
      (window[gtagName] = function () {
        // @ts-expect-error
        (window[dataLayerName] || (window[dataLayerName] = [])).push(arguments);
      })
    );
  }

  return () => {};
};

This solution comes from another issue and the solution presented here.

  1. Then we need to find a way to initialize the script before the plugin loads, because when the plugin loads it will begin to set tags and that will trigger the gtm.init event without the default consents.
export const GtagComponent = () => {
  const analytics = useAnalytics();
	// grab reference to script element
  const scriptElement: HTMLElement | null =
    typeof window !== 'undefined'
      ? document.getElementById('gtag-script')
      : null;

  const handleScriptLoad = () => {
    if ((scriptElement as any)?.readyState === 'loaded') {
      // set cookies in browser storage
      setInitialCookies([...defaultCookies]);
      setInitialCookies([...adCookies], true);
      try {
        (window as any).gtag = gtagFn((window as any).dataLayer, 'gtag');
        const gtag = gtagFn('dataLayer', 'gtag');
        // set cookies in gtm dataLayer
        if (typeof gtag === 'function') {
          gtag?.('consent', 'default', {
            ...getCookies([...defaultCookies]),
          });
          gtag?.('consent', 'default', {
            ...getCookies([...adCookies]),
          });
          // set redaction cookie by default
          gtag?.('set', redactionCookie, true); 
        }
        console.log('gtag initialized');
        if (getCookie(ANALYTICS_STORAGE_KEY) === '1') {
          analytics.plugins.enable('google-tag-manager');
        }
      } catch (error) {
        console.error('Failed to initialize gtag:', error);
      }
    } else {
      console.warn('gtag script not yet loaded, waiting...');
    }
  };
  
  useEffect(() => {
    if ((scriptElement as any)?.readyState === 'loaded') {
      console.log('found script element');
      handleScriptLoad();
      return;
    } 
      return () => {
        // remove script element from DOM - not sure if this is necessary.
        (scriptElement as HTMLScriptElement | null)?.remove();
      };
    }

  }, [scriptElement]);
  
  return (
    <Script
      strategy='lazyOnload'
      src={`https://www.googletagmanager.com/gtag/js?id=${env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID}`} // Replace with your Google Analytics ID
      id='gtag-script'
      onLoad={() => {
        try {
          (window as any).gtag = gtagFn((window as any).dataLayer, 'gtag'); // Invoke gtagFn after script loads
        } catch (error) {
          console.error('Error loading gtag', error);
        }
      }}
    />
  );
}

This is basically the complete work around, but I'll show how I've configured the rest to work including my analytics config

UPDATE: I just did some more digging into the plugins source, and noticed there is a way already defined to access the scriptLoad event internally.

And there is already a way to pass in a custom script -- Not sure how I missed these options in my first pass, but I will re-work my solution to see if i can take advantage of these apis.

The challenge now I guess is figuring out how to handle initial the consent before built in initialization method. I guess I can try extending the underlying plugin and see if that's the right strategy here.

Analytics Config:

export default function AnalyticsComponent({children}: PropsWithChildren<{}>) {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const doNotTrack = doNotTrackEnabled();
  const [consent, setConsent] = useState<boolean>(() => {
    if (!doNotTrack) {
      return hasCookie(COOKIE_CONSENT_KEY);
    } else {
      console.log('user has doNotTrackEnabled');
      return false;
    }
  });

  const analytics = Analytics({
    app: GTM_APP_NAME,
    debug: true,
    plugins: [
      googleTagManager({
        containerId: env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID,
        enabled: getCookie(ANALYTICS_STORAGE_KEY) === '1',
        dataLayer: 'dataLayer',
      }),
    ],
  });

  useEffect(() => {
    analytics.identify(GTM_APP_NAME);
  }, [analytics, pathname, searchParams]);

  return (
    <AnalyticsProvider instance={analytics}>
      {children}
      {!consent ?  (
        <>
        	<GtagComponent /> {/* injects gtag script to initialize consent */}
		      <ConsentBanner consent={consent} updateConsent={() => {}} />
        </>
      ) : null}
    </AnalyticsProvider>
  );
}

So with this workaround I currently inject a script into the DOM when there is no consent cookie set, then i run through and initialize the consent for each applicable cookie. And then, once consent is set I can initialize the plugin by using the analytics.plugins.enable method if they've allowed analytics_storage.

I'd love to wrap some of this functionality into a plugin, but I've come up short each time I've tried. I'd be more than happy to do the work if someone wants to help me understand the right pattern here.

Environment

  "dependencies": {
    "@analytics/google-analytics": "^1.0.7",
    "@analytics/google-tag-manager": "^0.5.5",
    "@vercel/analytics": "^1.2.0",
    "@vercel/speed-insights": "^1.0.10",
    "analytics": "^0.8.11",
    "analytics-plugin-do-not-track": "^0.1.5",
    "next": "14.1.0",
    "react": "^18",
    "react-dom": "^18",
    "use-analytics": "^1.1.0",
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "@types/use-analytics": "^0.0.3",
    "tsx": "^4.7.1",
    "typescript": "^5"
  }
@paul-vd
Copy link

paul-vd commented Nov 14, 2024

Currently working on something similar, for the initialization, you could wrap the initializer method, for example

function gtmConsent(plugin: AnalyticsPlugin) {
  return {
    ...plugin,

    initialize: (args) => {
      if (typeof plugin?.initialize !== "function") {
        return;
      }
      if (typeof window === "undefined") {
        plugin.initialize(args);
        return;
      }

      // see https://github.com/DavidWells/analytics/blob/master/packages/analytics-plugin-google-analytics/src/browser.js#L84-L132
      const { config } = args;

      // We initialize the gtag consent before script is injected
      const gtagName = config.gtagName;
      const dataLayerName = config.dataLayerName;

      if (!window[dataLayerName]) {
        window[dataLayerName] = window[dataLayerName] || [];
      }

      if (!window[gtagName]) {
        // @ts-expect-error - we are initializing the gtagName
        window[gtagName] = function () {
          // @ts-expect-error - we have initialized the dataLayerName
          window[dataLayerName].push(arguments);
        };
      }

      const consent = {
        ad_storage: "granted",
        ad_user_data: "granted",
        ad_personalization: "granted",
        analytics_storage: "granted",
      };

      // @ts-expect-error - we are initializing the gtagName
      window[gtagName]("consent", "default", consent);
      plugin.initialize(args);
      // @ts-expect-error - we are initializing the gtagName
      window[gtagName]("consent", "update", consent);
    },
  } satisfies AnalyticsPlugin;
}

then you just wrap your plugin with the gtmConsent

const analytics = Analytics({
  app: 'awesome-app',
  plugins: [
    gtmConsent(googleAnalytics({
      measurementIds: ['G-abc123']
    }))
  ]
})

In our usecase, we have a wrapper around getanalytics to only load plugins that have been granted consent, so we know if the plugin is initialized, we can run it with full permissions (granted), but this is something you would have to manage on your end, since I believe you initialize all your plugins.

I believe you would start with denied and then on a per consent bases you would run window[gtagName]("consent", "update", consent)

But yea this is very hacky solution.

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

No branches or pull requests

2 participants