Problem/Motivation

Since v0.7.10 klaro has own callback functions onAccept, onDecline and onInit. We need this callbacks for Google Consent Mode v2.

Google Consent Mode v2 (required for all advertisers from March 2024) expects a very specific consent handshake:

1. On page load (onInit): you must fire gtag('consent', 'default', { ad_storage: 'denied', ... }) before any tags execute. Without this default, Google treats consent as granted.
2. On accept (onAccept): fire gtag('consent', 'update', { ad_storage: 'granted' }).
3. On decline (onDecline): fire gtag('consent', 'update', { ad_storage: 'denied' }).

The old single callback_code field in Klaro ran at a fixed moment and couldn't reliably distinguish initialization from user interaction. The three separate callbacks (on_init, on_accept, on_decline) map exactly to the GCMv2 flow, giving site owners the right hook at the right moment. Developers have to do something like this to achieve a similar result rather than being able to follow Klaro's guidance/tutorial

E.g.

  if (type === 'load') {                                         
    // page load — set defaults                                  
  } else if (consent === true) {                                 
    // accepted                                                  
  } else {                                                       
    // declined                                                  
  }     

Without such an approach, gtag tags could fire before consent is established making the site technically non-compliant with GCMv2 requirements (and losing access to Google's modelled conversion data that GCMv2 enables).

Here is Klaro's tutorial on how to implement with the onAccept/onInit/onDecline https://klaro.org/docs/tutorials/google_tag_manager which needs this merge request to achieve.

Proposed resolution

Add these options to Klaro Apps to match Klaro's documentation per https://klaro.org/docs/tutorials/google_tag_manager

Remaining tasks

  • Create options and change Klaro App Form
  • Mark the existing hook as deprecated (the current callback was called on every change, so we cannot move the existing callback to the new onAccept, onDecline
  • Update apps in config/install

User interface changes

Settingsform for Klaro Apps.

Data model changes

Config for Klaro Apps.

Issue fork klaro-3484827

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

jan kellermann created an issue. See original summary.

jan kellermann’s picture

Version: 3.0.0-rc7 » 3.x-dev

kensae made their first commit to this issue’s fork.

kensae’s picture

I've added onInit, onAccept and onDecline options to the Klaro App entity and I've updated the form in MR!79

kensae’s picture

Status: Active » Needs review
ckng’s picture

StatusFileSize
new8.42 KB

MR!79 is working, only the form is working, but missing the JS functionality. Generated the patch file.

ckng’s picture

Status: Needs review » Needs work

Updated status, as JS logics are missing.

ckng’s picture

StatusFileSize
new11.38 KB

Updated patch. Passed onInit, onAccept, onDecline to services' config settings.

ckng’s picture

Status: Needs work » Needs review
ckng’s picture

StatusFileSize
new9.12 KB

Rebased and rerolled the patch.

jan kellermann’s picture

Big thanks to kensae and ckng. I have to check in the next days and will add some tests for the new options.

niklan’s picture

StatusFileSize
new740 bytes

I think the update for existing Klaro app entities should also be provided, since the entities now have new properties and the existing configurations are invalid for the new schema. I'm curious about whether this update should be more cautious, considering how long it has been since this issue was created. In its current state, it will overwrite all existing callback data after the update, which could be a bit intrusive. Perhaps only set a default value if no value exists at the time of the update? This way, projects that relied on this merge request until the official release will retain their configuration.

UPD. The default applications from config/install should also be updated to match the schema. I'm not sure if it's too aggressive to simply push such changes to the existing MR, or if it would be better to provide a patch instead?

UPD.2 The Google Analytics URL defaults to the German version ( info_url: 'https://marketingplatform.google.com/intl/de/about/analytics/') should it not be an English version?

jan kellermann’s picture

I think we cannot write an update hook for old javascript snippets. We have to use both variants in parallel and deprecate the old way in next major release.

niklan’s picture

Yes, we can't update the existing JS snippet. But, there are other problems related to the new properties:

  1. Shouldn't we provide a proper update for klaro_app entities in order to set default values for newly created properties? I have tested this locally and it looks like Drupal automatically detects this and, on the next configuration export, adds missing properties.
  2. Shouldn't we update the default klaro_app entities (./config/install) to match the schema? Drupal seems to do this at some point, by merging missing properties. But currently, they are becoming out of sync with actual entities, and this might be a problem in certain scenarios.
  3. The description of on_decline should be checked and updated to make it more specific when the callback is called. At the moment, I'm unsure which is correct, but it seems that the behavior is correct, so the description needs to be updated.
jan kellermann’s picture

Status: Needs review » Needs work

Thank you, niklan! You are of course absolutely right that we need an update hook for the addition of config entities and that the schema also needs to be updated. (I had understood that we would split the existing scripts into the new areas - which I think is too risky.)

mach3.zone’s picture

StatusFileSize
new29.64 KB

I played around with the events and observed the following sequences, starting without the klaro cookie:

  • A: Load: onInit, onDecline, deprecated User declines: onDecline, deprecated
  • B: Load: onInit, onDecline, deprecated User accepts: onAccept, deprecated

Subsequent page loads:
Load: onInit, onDecline|onAccept, deprecated

For me it looks like the behavior is sound, as niklan mentioned in #15.3
Applying this sequence for googles consent mode, one could use the onInit event to send the default consent state (denied) and then use the onAccept event to communicate a possible consent.

Regarding the description one could write, that the consent was explicit or also implicit, e.g.:
This javascript will be executed after the user has explicitly or implicitly (page load) declined the service.
Description suggestion

When it comes to the update script. The cautious approach would be to set the properties only, when not present. Something like:
$klaro_app->set('on_init', $klaro_app->get('on_init') ?: '');
right?

Regarding #15.2: For me this looks like a lot of work as all services would need to be migrated and probably tested. As far as I understand, they would just work like before. Why not adapt them one-by-one afterwards when needed, with people involved, who know these services well and can test them?

niklan’s picture

Regarding #15.2: For me this looks like a lot of work as all services would need to be migrated and probably tested.

In 15.2, I've talked only about entities in ./config/install, which are used for new installations (when the module is installed, like hook_install()). Changes here won't affect existing installations. However, it looks like Drupal provides missing data from default values, so it is mitigated in a way.

Basically, they are pre-exported entities, which are simply outdated from what they actually will be, but since the user will export configuration changes after a new module install regardless, they will be valid ones in the configuration sync directory.

This javascript will be executed after the user has explicitly or implicitly (page load) declined the service.

This one is much better, because it doesn't imply that user have to do any action for this event to occur.

seanmacgillivray’s picture

The patch in #13 has an update hook ID collision with another from https://www.drupal.org/project/klaro/issues/3484844, suggest incrementing it to 10015 or higher.

jan kellermann’s picture

Title: Refactor callback mechanism » [3.1.x] Refactor callback mechanism
scott_euser’s picture

StatusFileSize
new47.36 KB

Trying to understand how this works, in the deprecated callback its clear

Screenshot of callback UI

On load of Klaro, we can take one action or another based on consent.

But in the new UI proposed by this MR, how would that work. We have these scenarios right?

  1. onInit when the user has already granted consent
  2. onInit when the user has not yet granted consent
  3. onAccept when the user consents during the current page view
  4. onDecline when the user declines consent during the current page view

Is that right? If so, then how in onInit would we make the correct JS call based on already having accepted that service or not? The old callback form makes that distinction clear with the placeholder + description. Or maybe I misunderstand?

Thanks!

sascha_meissner’s picture

@scott_euser AFAIS,

- onInit will run on every load regardless of the consent, it is not possible to check the consent state in this callback as klaro is not fully initialized.

onAccept and onDecline would also run on every load, but based on the (implicit) consent, as well as when consent changes on runtime (explicit).

So you would have to move your code accordingly, probably you wont need the onInit callback at all.

Still you are right, the placeholders for the callbacks should be more explanative and should also show the available arguments, because , even though not explicitly mentioned in the upstream klaro-js docs, there are some:

- onInit (opts) opts.config, opts.service, opts.vars

- onAccept/onDecline(opts) opts.config, opts.consents, opts.confirmed, opts.service, opts.vars

scott_euser’s picture

Assigned: Unassigned » scott_euser

Great thanks for the details!

scott_euser’s picture

Assigned: scott_euser » Unassigned

Okay placeholders + field descriptions updated.

Still in 'Needs work' as per the discussion about update hooks.

sascha_meissner’s picture

Thx @scott_euser for your work.
I did also improve the placeholders, adding available arguments.

Maybe the description texts still need a little bit work, personally i more like the proposal from #17

dunx’s picture

Title: [3.1.x] Refactor callback mechanism » [3.1.x] Refactor callback mechanism (Google Consent Mode v2)

I just added 'Google Consent Mode v2' to the title. I'd been told there was an issue but was struggling to find it. Hopefully that's useful.

rafaellapinho’s picture

StatusFileSize
new10.94 MB

Timing Issue with Consent Mode Initialization
I have identified a significant issue with the timing of the Consent Mode data layer's creation using the current patch. This timing problem creates a race condition where tags can fire before the consent state is properly set, leading to non-compliance.

The Problem: Event Order
Ideally, the consent state should be set on the Consent Initialization event, which is the very first event that happens in Google Tag Manager (GTM). However, the current solution sets the consent data on the Consent Default event, which occurs much later.

This delayed initialization is problematic because it happens after other important events, including the Container Loaded event that fires the All Pages trigger. Many GTM tags rely on this trigger, causing them to fire without being able to properly check the user's consent status.

Case Study: Conversion Linker Tag
I encountered this exact issue while configuring the Conversion Linker tag.

The Conversion Linker tag is designed to read the gclid parameter from Google Ads and set the _gcl_au cookie, which is crucial for conversion tracking. The _gcl_au cookie should only be created if the user has accepted the marketing categories. Google recommends using the All Pages trigger for this tag.

However, because the All Pages trigger fires before the module has a chance to set the user's consent state, the Conversion Linker tag can fire and set the cookie without the user's explicit permission. This violates the principle of user consent and leads to compliance issues.

Current Workaround
To address this for now, I have implemented a different trigger for the Conversion Linker:

I created a custom event, such as consent_default, that fires specifically after the module has loaded and set the consent status.

I used this custom event as the primary trigger for the Conversion Linker tag.

I also added a second trigger that fires if the user later accepts cookies, ensuring the tag works for both initial page loads and subsequent user interactions.

Conclusion: A Better Solution Is Needed
While the custom trigger is a functional workaround, it is not a long-term solution. It creates manual overhead and introduces complexity that can lead to errors.

The ideal solution is to change when the consent data is pushed to the data layer. The module must be refactored to set the consent state on the Consent Initialization event, ensuring that it is available before any other triggers fire. This change is essential for a truly robust and compliant implementation that aligns with Google's recommended practices for Consent Mode.

sascha_meissner’s picture

Thank you very much @rafaellapinho for your feedback. Just for clarity, before digging deeper, u wrote that u used the "latest patch", did you mean the latest diff of 3484827-refactor-callback-mechanism from the issuefork or the actual "lates patch" from #13 ?

jan kellermann’s picture

I have a question to understand the problem: Klaro blocks the loading of external scripts. Only with consensus are the external scripts loaded. Why is an init code needed if the external scripts are not even loaded at this point?

And can you please post an example for onInit, onAccept and onDecline for GTM? Thank you in advance.

sascha_meissner’s picture

Title: [3.1.x] Refactor callback mechanism (Google Consent Mode v2) » [3.1.x] Refactor callback mechanism
Issue summary: View changes

I would suggest to seperate the Google Consent Mode v2 discussion and alignment to this issue: https://www.drupal.org/project/klaro/issues/3549323

And focus in this issue to just implement the callback improvements from the klaro-js library.
Therefore i´m removing Google consent mode from the title again.
AFAIS the only thing left to do is to adjust services in config/install, right? Updated the issue summary accordingly

scott_euser’s picture

Will try to answer to the best of my understanding below:

Why is an init code needed if the external scripts are not even loaded at this point?

Google Analytics (often loaded via GTM) has for example Cookieless Pings https://support.google.com/analytics/answer/13802165?hl=en when analytics_storage is denied. This allows it to model overall traffic based on those users who accept cookies (after it collects sufficient data).

You can see Klaro's documentation example here in "Example Config" section: https://klaro.org/docs/tutorials/google_tag_manager where they show how you would use onInit in this scenario, then change it in onAccept

And can you please post an example for onInit, onAccept and onDecline for GTM? Thank you in advance.

Here is the example from Klaro's docs (linked above) for reference + to answer this

var klarConfig = {
    acceptAll: true,
    services: [
        {
            name: 'google-tag-manager',
            required: true,
            purposes: ['marketing'],
            onAccept: `
                // we notify the tag manager about all services that were accepted. You can define
                // a custom event in GTM to load the service if consent was given.
                for(let k of Object.keys(opts.consents)){
                    if (opts.consents[k]){
                        let eventName = 'klaro-'+k+'-accepted'
                        dataLayer.push({'event': eventName})
                    }
                }
            `,
            onInit: `
                // initialization code here (will be executed only once per page-load)
                window.dataLayer = window.dataLayer || [];
                window.gtag = function(){dataLayer.push(arguments)}
                gtag('consent', 'default', {'ad_storage': 'denied', 'analytics_storage': 'denied', 'ad_user_data': 'denied', 'ad_personalization': 'denied'})
                gtag('set', 'ads_data_redaction', true)
            `,
        },
        {
            // In GTM, you should define a custom event trigger named `klaro-google-analytics-accepted` which should trigger the Google Analytics integration.
            name: 'google-analytics',
            cookies: [
                /^_ga(_.*)?/ // we delete the Google Analytics cookies if the user declines its use
            ],
            purposes: ['marketing'],
            onAccept: `
                // we grant analytics storage
                gtag('consent', 'update', {
                    'analytics_storage': 'granted',
                })
            `,
            onDecline: `
                // we deny analytics storage
                gtag('consent', 'update', {
                    'analytics_storage': 'denied',
                })
            `,
        },
        {
            name: 'google-ads',
            cookies: [],
            onAccept: `
                // we grant ad storage and personalization
                gtag('consent', 'update', {
                    'ad_storage': 'granted',
                    'ad_user_data': 'granted',
                    'ad_personalization': 'granted'
                })
            `,
            onDecline: `
                // we decline ad storage and personalization
                gtag('consent', 'update', {
                    'ad_storage': 'denied',
                    'ad_user_data': 'denied',
                    'ad_personalization': 'denied'
                })
            `,
            purposes: ['marketing'],
        }
    ]
}
I would suggest to seperate the Google Consent Mode v2 discussion and alignment to this issue: https://www.drupal.org/project/klaro/issues/3549323

Its very possible to be consent mode v2 compliant as is, aligning the code here in Klaro with the documentation of Klaro's callback's makes that achievable. Can we have that issue perhaps dealing with setting a default install state that matches consent mode as a follow-up to this to not derail this?

anybody’s picture

Please also note. The functionality to not break the banner (what ever happens in the callback) should be integrated here!

scott_euser’s picture

Issue summary: View changes
Status: Needs work » Needs review

Added the update hook + test for it, and added try/catch-wrapped function for onInit, onAccept, and onDecline to match the callback code "Execute callbackCode from config form" bit. I think that is all outstanding issues

kensae’s picture

I've been testing this changes in combination with the different callbacks to implement with google consent mode v2 and it works for me.

jan kellermann’s picture

Version: 3.x-dev » 3.1.x-dev

scott_euser’s picture

Title: [3.1.x] Refactor callback mechanism » [3.1.x] Refactor callback mechanism for compatability with Google Consent Mode v2
Issue summary: View changes

scott_euser changed the visibility of the branch 3484827-refactor-callback-mechanism to hidden.

scott_euser changed the visibility of the branch 3.1.x to hidden.

scott_euser changed the visibility of the branch 3.x to hidden.

scott_euser’s picture

Okay no changes made, just updated to target 3.1.x comment #35 and ensured no changes are made in the MR that are not directly related. I updated the title & issue summary to be more clear on the implications and rationale.

Would be keen to have someone review and RTBC this to avoid indefinitely spending time and energy keeping this merge request up to date with module. Thank you in advance!

shiraz dindar’s picture

Status: Needs review » Reviewed & tested by the community

Reviewed MR !140 end-to-end against klaro 3.1.0 — applies cleanly
via the .diff URL. Logic is sound: callbacks wrapped as error-safe
functions with try/catch, update hook migrates existing apps,
schema and install configs updated, tests cover both the API and
the migration. Existing `callback_code` field preserved →
backwards compatible.

We've been running this in production on mises.org since
mid-August 2025 (~10 months) using `on_accept` on the GTM klaro
app to fire Google Consent Mode v2 updates:

on_accept: |
gtag('consent', 'update', {
ad_storage: 'granted',
analytics_storage: 'granted',
ad_user_data: 'granted',
ad_personalization: 'granted'
});

No issues observed.

Setting to RTBC.

scott_euser’s picture

Resolved conflicts

jan kellermann’s picture

Status: Reviewed & tested by the community » Needs work

Thank you for your great work! I would like to merge and tag the new release in the short term.

I'd like to add some apps for the new mode based on this documentation:

- Google Tag Manager (GTM) with Google Consent Mode v2
- Google Analytics with GTM and Google Consent Mode v2
- Google Ads with GTM and Google Consent Mode v2

And the Readme needs to be updated:
https://git.drupalcode.org/project/klaro/-/blob/3.1.x/README.md?ref_type...

Since we don't work with Google, we rely on your support here. Thank you very much!

scott_euser’s picture

Assigned: Unassigned » scott_euser

Sounds good, thanks

scott_euser’s picture

Assigned: scott_euser » Unassigned

Hmmm @jan-kellerman because these app exists in non-consent mode version I think we need to do this order:

  1. Merge this
  2. New issue to separate user-facing label from admin label (updated hook etc to add new config)
  3. Add new configs with separate admin labels / readme instructions

If you agree I'll create 2 & 3 and will work on them. Otherwise either the labels in the admin > config area or the user facing labels will be confusing; whereas we kinda need more like Views does with an admin label separate from the user facing label.

As an side/tangent: I wonder if its useful to provide an inline instructions tab; there is corresponding GTM instructions that a user needs to follow rather than just having those in a readme. That could be an issue 4.

jan kellermann’s picture

My idea was just to use other labels for the new Apps like suggested and describe the new apps in the readme.

  • Google Tag Manager (GTM) with Google Consent Mode v2 (gtm_gcm2)
  • Google Analytics with GTM and Google Consent Mode v2 (ga_gcm2)
  • Google Ads with GTM and Google Consent Mode v2 (gads_gcm2)

I will write a Change Note for using GCM v2.

scott_euser’s picture

Status: Needs work » Needs review

Okay after discussing briefly in Slack, since the labels are user-facing as well, we agreed to rename the old ones to have (legacy) suffix, so the user is not faced with trying to understand what 'Consent mode' means. The texts are identical to the originals (though Google Ads never existed in config/install/ so that's new). The init/accept/decline are filled in as appropriate, and described in the readme.

jan kellermann’s picture

Thank you. Looks good from my side. Maybe somebody can check the new recipe.

scott_euser’s picture

Status: Needs review » Reviewed & tested by the community
StatusFileSize
new62.24 KB
new80.57 KB

Perfect thanks! Recipe works well as well. I tried it out on an existing site where config diverged as well and still looks good:

List of services updated:

List of services

New service shows e.g. onAccept prepared:

New service shows e.g. onAccept used

Thank you!

jan kellermann’s picture

Status: Reviewed & tested by the community » Fixed

New version 3.1.1 is tagged. Thank you all for working over such a long time. Special thanks go to Scott for the final sprint we did together.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

scott_euser’s picture

Great thank you very much as well!

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.