@endora-commerce/mod-google-tag-manager 0.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/backend/index.d.ts +62 -0
  4. package/dist/backend/index.d.ts.map +1 -0
  5. package/dist/backend/index.js +70 -0
  6. package/dist/backend/index.js.map +1 -0
  7. package/dist/backend/routes.storefront.d.ts +16 -0
  8. package/dist/backend/routes.storefront.d.ts.map +1 -0
  9. package/dist/backend/routes.storefront.js +40 -0
  10. package/dist/backend/routes.storefront.js.map +1 -0
  11. package/dist/backend/services/gtm-config.service.d.ts +27 -0
  12. package/dist/backend/services/gtm-config.service.d.ts.map +1 -0
  13. package/dist/backend/services/gtm-config.service.js +65 -0
  14. package/dist/backend/services/gtm-config.service.js.map +1 -0
  15. package/dist/backend/services/sgtm-client.d.ts +46 -0
  16. package/dist/backend/services/sgtm-client.d.ts.map +1 -0
  17. package/dist/backend/services/sgtm-client.js +69 -0
  18. package/dist/backend/services/sgtm-client.js.map +1 -0
  19. package/dist/backend/services/ss-relay-queue.d.ts +46 -0
  20. package/dist/backend/services/ss-relay-queue.d.ts.map +1 -0
  21. package/dist/backend/services/ss-relay-queue.js +29 -0
  22. package/dist/backend/services/ss-relay-queue.js.map +1 -0
  23. package/dist/backend/services/ss-relay.service.d.ts +24 -0
  24. package/dist/backend/services/ss-relay.service.d.ts.map +1 -0
  25. package/dist/backend/services/ss-relay.service.js +77 -0
  26. package/dist/backend/services/ss-relay.service.js.map +1 -0
  27. package/dist/manifest.d.ts +194 -0
  28. package/dist/manifest.d.ts.map +1 -0
  29. package/dist/manifest.js +117 -0
  30. package/dist/manifest.js.map +1 -0
  31. package/docs/google-tag-manager.md +228 -0
  32. package/i18n/en.json +4 -0
  33. package/i18n/pl.json +4 -0
  34. package/package.json +64 -0
@@ -0,0 +1,77 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { GOOGLE_TAG_MANAGER_SETTING_CODES } from '@endora-commerce/contracts';
4
+ /**
5
+ * Server-side relay orchestration (feature 066, US3).
6
+ *
7
+ * - `makeEnqueuer` returns the producer used by the `/collect` route: it
8
+ * assigns one uuid `eventId` per event (the idempotency key and the BullMQ
9
+ * `jobId`) and enqueues one relay job each. Pure producer — no outbound call
10
+ * happens in the shopper's request (Principle X / FR-029).
11
+ * - `makeProcessor` returns the BullMQ processor: it resolves the channel's
12
+ * server container address from Settings, drops the visitor's IP and user
13
+ * agent unless consent was granted (FR-030), and forwards through the sGTM
14
+ * client, letting a failure throw so BullMQ retries.
15
+ */
16
+ export function makeEnqueuer(queue) {
17
+ return async (salesChannelId, request, context) => {
18
+ // One timestamp for the whole batch: it is when the visitor acted, not when
19
+ // a retry happened to reach the destination.
20
+ const occurredAt = new Date().toISOString();
21
+ let accepted = 0;
22
+ for (const event of request.events) {
23
+ const eventId = randomUUID();
24
+ await queue.add('relay', {
25
+ eventId,
26
+ salesChannelId,
27
+ clientId: request.clientId,
28
+ // `gtm_event_id` travels in the params so the operator's container
29
+ // can deduplicate a retried delivery.
30
+ event: { name: event.name, params: { ...event.params, gtm_event_id: eventId } },
31
+ consent: request.consent,
32
+ page: request.page,
33
+ ...(context.ip ? { ip: context.ip } : {}),
34
+ ...(context.userAgent ? { userAgent: context.userAgent } : {}),
35
+ occurredAt,
36
+ }, { jobId: eventId });
37
+ accepted += 1;
38
+ }
39
+ return accepted;
40
+ };
41
+ }
42
+ export function makeProcessor(deps) {
43
+ return async (job) => {
44
+ const { salesChannelId } = job.data;
45
+ const baseUrl = await read(deps.settings, GOOGLE_TAG_MANAGER_SETTING_CODES.SERVER_CONTAINER_URL, salesChannelId);
46
+ if (!baseUrl.trim()) {
47
+ // The operator cleared the address between enqueue and delivery. That is
48
+ // a permanent no-op, not a transient failure — returning rather than
49
+ // throwing keeps it from burning eight retries.
50
+ return;
51
+ }
52
+ const ingestPath = await read(deps.settings, GOOGLE_TAG_MANAGER_SETTING_CODES.SERVER_INGEST_PATH, salesChannelId);
53
+ const granted = job.data.consent.analyticsStorage === 'granted';
54
+ const event = {
55
+ eventId: job.data.eventId,
56
+ clientId: job.data.clientId,
57
+ name: job.data.event.name,
58
+ params: job.data.event.params,
59
+ consent: job.data.consent,
60
+ page: job.data.page,
61
+ // FR-030 — the visitor's IP and user agent travel only with consent.
62
+ ...(granted && job.data.ip ? { ip: job.data.ip } : {}),
63
+ ...(granted && job.data.userAgent ? { userAgent: job.data.userAgent } : {}),
64
+ };
65
+ await deps.client.send({ baseUrl, ingestPath }, event);
66
+ };
67
+ }
68
+ /** A destination read must never fail the job on an unregistered setting. */
69
+ async function read(settings, code, salesChannelId) {
70
+ try {
71
+ return await settings.get(code, salesChannelId, z.string());
72
+ }
73
+ catch {
74
+ return '';
75
+ }
76
+ }
77
+ //# sourceMappingURL=ss-relay.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ss-relay.service.js","sourceRoot":"","sources":["../../../src/backend/services/ss-relay.service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Google Tag Manager module (feature 066). Puts the operator's GTM container on
3
+ * the storefront per sales channel, publishes a documented commerce `dataLayer`
4
+ * vocabulary for their tags to trigger on, and optionally relays the
5
+ * relay-eligible subset of those events to their server-side container.
6
+ *
7
+ * The module owns no table and no admin page: every tag, trigger and variable
8
+ * lives in the GTM console, and the six values below are managed on the generic
9
+ * Settings screen (research §§R2-R3). Consequently it declares no permission
10
+ * codes — the one palette action references the core `settings:read` code.
11
+ */
12
+ export declare const googleTagManagerSettingsManifest: {
13
+ moduleCode: string;
14
+ groups: {
15
+ code: string;
16
+ name: string;
17
+ salesChannelCodes?: string[] | undefined;
18
+ isSystemProtected?: boolean | undefined;
19
+ }[];
20
+ settings: {
21
+ code: string;
22
+ name: string;
23
+ valueType: "string" | "number" | "boolean" | "json" | "string_list" | "secret" | "credential_ref";
24
+ defaultValue: unknown;
25
+ description?: string | undefined;
26
+ groupCode?: string | undefined;
27
+ previousDefaultValues?: unknown[] | undefined;
28
+ salesChannelCodes?: string[] | undefined;
29
+ enumOptions?: string[] | undefined;
30
+ configurationType?: string | undefined;
31
+ hidden?: boolean | undefined;
32
+ }[];
33
+ };
34
+ export declare const manifest: {
35
+ id: string;
36
+ name: string;
37
+ version: string;
38
+ dependencies: string[];
39
+ description?: string | undefined;
40
+ acknowledgedDependencies?: {
41
+ moduleId: string;
42
+ port: string;
43
+ reason: string;
44
+ }[] | undefined;
45
+ nonBindingDependencies?: {
46
+ moduleId: string;
47
+ name: string;
48
+ kind: "contributes-to" | "degrades-without" | "refuses-without";
49
+ reason: string;
50
+ whenAbsent?: string | undefined;
51
+ }[] | undefined;
52
+ activation?: {
53
+ settingCode: string;
54
+ default: boolean;
55
+ } | {
56
+ nonDeactivatable: true;
57
+ reason: string;
58
+ } | undefined;
59
+ settings?: {
60
+ moduleCode: string;
61
+ groups: {
62
+ code: string;
63
+ name: string;
64
+ salesChannelCodes?: string[] | undefined;
65
+ isSystemProtected?: boolean | undefined;
66
+ }[];
67
+ settings: {
68
+ code: string;
69
+ name: string;
70
+ valueType: "string" | "number" | "boolean" | "json" | "string_list" | "secret" | "credential_ref";
71
+ defaultValue: unknown;
72
+ description?: string | undefined;
73
+ groupCode?: string | undefined;
74
+ previousDefaultValues?: unknown[] | undefined;
75
+ salesChannelCodes?: string[] | undefined;
76
+ enumOptions?: string[] | undefined;
77
+ configurationType?: string | undefined;
78
+ hidden?: boolean | undefined;
79
+ }[];
80
+ } | undefined;
81
+ i18n?: {
82
+ bundlesDir: string;
83
+ } | undefined;
84
+ docs?: false | {
85
+ dir: string;
86
+ } | undefined;
87
+ demo?: false | {
88
+ summary: string;
89
+ seed: (context: import("@endora-commerce/contracts").ModuleDemoContext<never>) => Promise<import("@endora-commerce/contracts").DemoSeedResult>;
90
+ reset: (context: import("@endora-commerce/contracts").ModuleDemoContext<never>) => Promise<import("@endora-commerce/contracts").DemoResetResult>;
91
+ after?: readonly string[] | undefined;
92
+ package?: string | undefined;
93
+ } | undefined;
94
+ actions?: {
95
+ id: string;
96
+ labelKey: string;
97
+ icon: "Plus" | "Sparkles" | "Settings" | "Search" | "Boxes" | "Layers" | "Menu" | "PlusCircle" | "PlusSquare" | "FilePlus" | "FolderPlus" | "Upload" | "FileUp" | "CloudUpload" | "Download" | "FileDown" | "FileText" | "BookOpen" | "Rss" | "Package" | "Tag" | "ShoppingCart" | "Receipt" | "CreditCard" | "Users" | "UserPlus" | "Inbox" | "ListChecks" | "ClipboardList" | "Image" | "Video" | "LayoutDashboard" | "PanelLeft" | "KeyRound" | "ShieldCheck" | "Edit" | "Archive" | "Box" | "Truck" | "CircleDollarSign" | "Activity" | "LineChart" | "Smartphone" | "Webhook" | "Scale" | "PlugZap" | "PercentDiamond" | "Newspaper" | "Languages" | "Eraser" | "Warehouse" | "TrendingDown" | "Bell" | "PackageOpen" | "Building2" | "Store" | "ClipboardCheck";
98
+ targetRoute: string;
99
+ keywords: string[];
100
+ weight: number;
101
+ descriptionKey?: string | undefined;
102
+ requiredPermission?: string | undefined;
103
+ }[] | undefined;
104
+ permissions?: {
105
+ code: string;
106
+ label: string;
107
+ module?: string | undefined;
108
+ description?: string | undefined;
109
+ requires?: string[] | undefined;
110
+ }[] | undefined;
111
+ transactionalEmails?: {
112
+ code: string;
113
+ name: string;
114
+ variables: {
115
+ key: string;
116
+ label: string;
117
+ sampleValue?: string | undefined;
118
+ description?: string | undefined;
119
+ }[];
120
+ description?: string | undefined;
121
+ group?: string | undefined;
122
+ }[] | undefined;
123
+ capabilities?: string[] | undefined;
124
+ exclusiveCapabilities?: {
125
+ key: string;
126
+ errorCode: string;
127
+ }[] | undefined;
128
+ errorCodes?: {
129
+ code: string;
130
+ tokens?: string[] | undefined;
131
+ }[] | undefined;
132
+ blocks?: {
133
+ name: string;
134
+ labelKey: string;
135
+ category: string;
136
+ contexts: ("invoice" | "email" | "cms" | "newsletter")[];
137
+ fields: Record<string, {
138
+ type: "number" | "object" | "array" | "text" | "textarea" | "select" | "radio" | "external" | "uuid" | "richtext";
139
+ label?: string | undefined;
140
+ required?: boolean | undefined;
141
+ options?: {
142
+ label: string;
143
+ value: string | number;
144
+ }[] | undefined;
145
+ refKind?: string | undefined;
146
+ }>;
147
+ descriptionKey?: string | undefined;
148
+ defaultProps?: Record<string, unknown> | undefined;
149
+ responsiveFields?: string[] | undefined;
150
+ previewIcon?: string | undefined;
151
+ weight?: number | undefined;
152
+ }[] | undefined;
153
+ blockCategories?: {
154
+ key: string;
155
+ titleKey: string;
156
+ contexts: ("invoice" | "email" | "cms" | "newsletter")[];
157
+ weight?: number | undefined;
158
+ visible?: boolean | undefined;
159
+ }[] | undefined;
160
+ env?: {
161
+ name: string;
162
+ describes: {
163
+ en: string;
164
+ pl: string;
165
+ };
166
+ requirement: {
167
+ kind: "required";
168
+ } | {
169
+ kind: "requiredWhen";
170
+ input: string;
171
+ equals: string;
172
+ } | {
173
+ kind: "optional";
174
+ without: {
175
+ en: string;
176
+ pl: string;
177
+ };
178
+ };
179
+ secret: boolean;
180
+ generable: boolean;
181
+ owner: {
182
+ kind: "platform";
183
+ } | {
184
+ kind: "application";
185
+ application: "admin" | "backend" | "storefront";
186
+ } | {
187
+ kind: "module";
188
+ moduleId: string;
189
+ };
190
+ consumers: ("admin" | "backend" | "storefront")[];
191
+ addressOf: "admin" | "backend" | "storefront" | null;
192
+ }[] | undefined;
193
+ };
194
+ //# sourceMappingURL=manifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gCAAgC;;;;;;;;;;;;;;;;;;;;;CAwE3C,CAAC;AAEH,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCnB,CAAC"}
@@ -0,0 +1,117 @@
1
+ import { defineModuleManifest, defineModuleSettingsManifest } from '@endora-commerce/contracts';
2
+ import { GOOGLE_TAG_MANAGER_SETTING_CODES } from '@endora-commerce/contracts';
3
+ /**
4
+ * Google Tag Manager module (feature 066). Puts the operator's GTM container on
5
+ * the storefront per sales channel, publishes a documented commerce `dataLayer`
6
+ * vocabulary for their tags to trigger on, and optionally relays the
7
+ * relay-eligible subset of those events to their server-side container.
8
+ *
9
+ * The module owns no table and no admin page: every tag, trigger and variable
10
+ * lives in the GTM console, and the six values below are managed on the generic
11
+ * Settings screen (research §§R2-R3). Consequently it declares no permission
12
+ * codes — the one palette action references the core `settings:read` code.
13
+ */
14
+ export const googleTagManagerSettingsManifest = defineModuleSettingsManifest({
15
+ moduleCode: 'google_tag_manager',
16
+ groups: [{ code: 'google_tag_manager', name: 'Google Tag Manager' }],
17
+ settings: [
18
+ {
19
+ // Feature 073 — the operator's activation control. Platform-wide, and
20
+ // deliberately not `google_tag_manager.enabled`: that code already exists
21
+ // and is per-sales-channel, answering "does the container load on this
22
+ // storefront". This one answers "does this client have GTM at all".
23
+ code: 'google_tag_manager.module_enabled',
24
+ name: 'Google Tag Manager module enabled',
25
+ description: 'Switches the Google Tag Manager container injection and the server-side relay on or off for the whole platform. Separate from the per-channel switch, which decides where the container actually loads. Nothing is dropped: every setting keeps its value.',
26
+ groupCode: 'google_tag_manager',
27
+ valueType: 'boolean',
28
+ defaultValue: true,
29
+ },
30
+ {
31
+ code: GOOGLE_TAG_MANAGER_SETTING_CODES.ENABLED,
32
+ name: 'Enable Google Tag Manager',
33
+ description: 'Master switch for the module. Per-channel overridable. If your container also contains a GA4 tag, do not enable the platform\'s Google Analytics module for the same channel: both would report the same actions and every metric would be doubled.',
34
+ groupCode: 'google_tag_manager',
35
+ valueType: 'boolean',
36
+ defaultValue: false,
37
+ },
38
+ {
39
+ code: GOOGLE_TAG_MANAGER_SETTING_CODES.CONTAINER_ID,
40
+ name: 'Container ID',
41
+ description: 'GTM container ID (GTM-XXXXXXX) from the Google Tag Manager console. Blank means the channel is untracked.',
42
+ groupCode: 'google_tag_manager',
43
+ valueType: 'string',
44
+ defaultValue: '',
45
+ },
46
+ {
47
+ code: GOOGLE_TAG_MANAGER_SETTING_CODES.REQUIRE_CONSENT,
48
+ name: 'Require analytics consent',
49
+ description: 'When enabled, the container starts with Consent Mode v2 set to denied and the platform sends no events until the visitor accepts the cookie banner. Consent Mode governs Google tags automatically; a non-Google tag in your container fires unless you add an additional consent check to it.',
50
+ groupCode: 'google_tag_manager',
51
+ valueType: 'boolean',
52
+ defaultValue: true,
53
+ },
54
+ {
55
+ code: GOOGLE_TAG_MANAGER_SETTING_CODES.SERVER_SIDE_ENABLED,
56
+ name: 'Server-side tagging',
57
+ description: 'Deliver the platform\'s commerce events to your server-side GTM container from the backend instead of the browser. This moves those events out of the web container: tags that trigger on them must exist in the server container. Triggers configured inside the web container (scroll, clicks, visibility, forms) are unaffected.',
58
+ groupCode: 'google_tag_manager',
59
+ valueType: 'boolean',
60
+ defaultValue: false,
61
+ },
62
+ {
63
+ code: GOOGLE_TAG_MANAGER_SETTING_CODES.SERVER_CONTAINER_URL,
64
+ name: 'Server container URL',
65
+ description: 'Base URL of your server-side GTM container, e.g. https://sgtm.example.com. Blank keeps the channel on the browser path even when server-side tagging is on.',
66
+ groupCode: 'google_tag_manager',
67
+ valueType: 'string',
68
+ defaultValue: '',
69
+ },
70
+ {
71
+ code: GOOGLE_TAG_MANAGER_SETTING_CODES.SERVER_INGEST_PATH,
72
+ name: 'Server container ingest path',
73
+ description: "Request path your server container's client listens on. Default /data matches Google's Data Client; change it only if your container uses a custom client.",
74
+ groupCode: 'google_tag_manager',
75
+ valueType: 'string',
76
+ defaultValue: '/data',
77
+ },
78
+ ],
79
+ });
80
+ export const manifest = defineModuleManifest({
81
+ id: 'google_tag_manager',
82
+ name: 'Google Tag Manager',
83
+ description: 'Google Tag Manager integration: per-sales-channel container injection behind the storefront consent decision, a documented commerce dataLayer vocabulary, and an optional server-side tagging relay.',
84
+ version: '1.0.0',
85
+ dependencies: ['sales_channels', 'settings'],
86
+ settings: googleTagManagerSettingsManifest,
87
+ i18n: { bundlesDir: 'i18n' },
88
+ docs: { dir: 'docs' },
89
+ actions: [
90
+ {
91
+ id: 'open-google-tag-manager',
92
+ labelKey: 'actions.openGoogleTagManager.label',
93
+ descriptionKey: 'actions.openGoogleTagManager.description',
94
+ icon: 'Settings',
95
+ // The module has no page of its own — its settings group lives on the
96
+ // generic Settings screen, which has a search box (research §R3).
97
+ targetRoute: '/settings',
98
+ // Core catalogue code, referenced rather than re-declared: this module
99
+ // owns no permission of its own.
100
+ requiredPermission: 'settings:read',
101
+ keywords: [
102
+ 'gtm',
103
+ 'google',
104
+ 'tag',
105
+ 'manager',
106
+ 'container',
107
+ 'datalayer',
108
+ 'sgtm',
109
+ 'tagi',
110
+ 'kontener',
111
+ ],
112
+ weight: 246,
113
+ },
114
+ ],
115
+ activation: { settingCode: 'google_tag_manager.module_enabled', default: true },
116
+ });
117
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,228 @@
1
+ ---
2
+ title: Google Tag Manager
3
+ description: Google Tag Manager containers per sales channel, with a documented commerce dataLayer and an optional server-side relay
4
+ ---
5
+
6
+ # Google Tag Manager
7
+
8
+ The `google_tag_manager` module puts your **GTM container** on the storefront, one per
9
+ sales channel, and publishes a documented commerce `dataLayer` vocabulary for your tags to trigger
10
+ on. It can also relay those events from the platform's backend to your **server-side GTM container**.
11
+
12
+ ## What this module does, and what it does not
13
+
14
+ Google Tag Manager is a *tag container*, not a measurement product. Every tag, trigger and variable
15
+ lives in the Google Tag Manager console, outside this platform. So the module's job is narrow on
16
+ purpose:
17
+
18
+ **It does:**
19
+
20
+ - load the right container on the right channel's storefront, behind the storefront's single consent
21
+ decision;
22
+ - publish a stable, documented set of commerce events onto `window.dataLayer`;
23
+ - optionally deliver the relay-eligible subset of those events to your server container, durably and
24
+ exactly once.
25
+
26
+ **It does not:**
27
+
28
+ - model, mirror or manage your tags, triggers or variables — those stay in the GTM console;
29
+ - own a database table or an admin page of its own. Its whole configuration is six settings on the
30
+ generic Settings screen;
31
+ - decide anything about which vendors your container talks to.
32
+
33
+ ## Configuration (Settings module)
34
+
35
+ All values live in the **Google Tag Manager** settings group and are overridable per sales channel.
36
+
37
+ | Setting | Type | Default | Meaning |
38
+ | --- | --- | --- | --- |
39
+ | `google_tag_manager.enabled` | boolean | `false` | Master switch for the channel. |
40
+ | `google_tag_manager.container_id` | string | `''` | Container ID, `GTM-XXXXXXX`. Blank ⇒ the channel is untracked. |
41
+ | `google_tag_manager.require_consent` | boolean | `true` | Start the container under Consent Mode v2 denied and send no platform events until the visitor accepts. |
42
+ | `google_tag_manager.server_side_enabled` | boolean | `false` | Relay commerce events from the backend to your server container. |
43
+ | `google_tag_manager.server_container_url` | string | `''` | Base URL of your server container, e.g. `https://sgtm.example.com`. |
44
+ | `google_tag_manager.server_ingest_path` | string | `/data` | Request path your container's ingest client claims. |
45
+
46
+ A channel is tracked only when the master switch is on **and** the container ID has the `GTM-…`
47
+ shape. A blank or malformed ID means "not configured", never a broken script tag, so clearing it is
48
+ a safe way to pause a channel. Configuration changes reach the storefront immediately — no redeploy,
49
+ no cache wait.
50
+
51
+ There is no admin page: six settings and no rows of its own means the generic Settings screen already
52
+ does everything a bespoke page could. Under the command palette, "Google Tag Manager" lands there.
53
+
54
+ ## Double counting with the native Google Analytics module
55
+
56
+ > **Do not run a GA4 tag inside your container *and* the platform's Google Analytics module on the
57
+ > same channel.** Both report the same shopping actions, so every metric doubles.
58
+
59
+ The platform cannot detect this: the GA4 tag lives in your container, where the platform has no
60
+ visibility. It also does not enforce mutual exclusion — the two integrations stay independent
61
+ switches and the choice is yours. Pick one measurement path per channel:
62
+
63
+ - **GTM as the single path** — turn the platform's Google Analytics module off for the channel and
64
+ build your GA4 tag in the container. GTM tracking is unaffected by that switch.
65
+ - **The native module as the single path** — keep GTM for everything that is not GA4.
66
+
67
+ ## Storefront behaviour
68
+
69
+ The container loads with `next/script` on `afterInteractive`, so it never blocks the critical
70
+ rendering path. The standard `<noscript>` iframe fallback is rendered too, so the container is
71
+ reachable without JavaScript.
72
+
73
+ ### Consent
74
+
75
+ Consent is the storefront's single decision, shared with Google Analytics, LinkedIn Ads and Meta
76
+ Ads: one banner, one stored answer. The banner is shown whenever **any** enabled integration on the
77
+ channel requires consent — including when Google Tag Manager is the only one enabled.
78
+
79
+ With `Require analytics consent` on:
80
+
81
+ 1. before the container loads, the page publishes Consent Mode v2 defaults of `denied` for
82
+ `analytics_storage`, `ad_storage`, `ad_user_data` and `ad_personalization`;
83
+ 2. the container itself **is** loaded. A container is not a tag: withholding it would silently
84
+ disable every tag you built, including ones that never needed consent;
85
+ 3. the platform pushes **no events of its own** — no page views, no commerce events, no relay —
86
+ until the visitor accepts;
87
+ 4. accepting mid-session delivers a Consent Mode `update` to the container and starts the platform's
88
+ events flowing, with no page reload.
89
+
90
+ > **Consent Mode governs Google tags, not everyone else's.** Google's own tags read the consent state
91
+ > and withhold storage and identifiers automatically. A Meta, LinkedIn or custom HTML tag in your
92
+ > container **fires regardless** unless you add an **additional consent check** to it in the GTM
93
+ > console. The platform cannot see or enforce that — it is your obligation.
94
+
95
+ ## The event vocabulary
96
+
97
+ Trigger your tags on these custom events. The `items` parameter is a **JSON string** using the GA4
98
+ recommended item shape (`item_id`, `item_name`, `price`, `quantity`), because virtually every
99
+ container maps it to a GA4 tag.
100
+
101
+ | Event | Parameters | Relay-eligible |
102
+ | --- | --- | --- |
103
+ | `page_view` | `page_path`, `page_location`, `page_title` | yes |
104
+ | `view_search_results` | `search_term` | yes |
105
+ | `view_item` | `currency`, `value`, `items` | yes |
106
+ | `add_to_cart` | `currency`, `value`, `items` | yes |
107
+ | `add_to_quote_request` | `currency`, `value`, `items` | yes |
108
+ | `add_to_shopping_list` | `items` | yes |
109
+ | `begin_checkout` | `currency`, `value`, `items` | yes |
110
+ | `place_order_clicked` | the checkout submission fields | yes |
111
+ | `purchase` | `transaction_id`, `value`, `currency`, `items` | yes |
112
+ | `contact_form_submitted` | the contact-form field values | yes |
113
+
114
+ Event payloads carry scalar values only, so an uploaded file or attachment can never appear in one.
115
+
116
+ > **Trigger `page_view`, not History Change.** The storefront pushes exactly one `page_view` per
117
+ > destination, including navigations that do not reload the document. If your container also uses
118
+ > GTM's built-in History Change trigger you will count every navigation twice.
119
+
120
+ ### Client-only events
121
+
122
+ These are **not** published by the platform and have no server-side path, by design:
123
+
124
+ | Trigger | Why the platform cannot reconstruct it |
125
+ | --- | --- |
126
+ | Scroll depth | Depends on viewport size and live scroll position. |
127
+ | Outbound link clicks | Needs the clicked element and the live DOM. |
128
+ | File downloads | Needs the clicked link's href and text. |
129
+ | `form_start` / `form_submit` | DOM interaction timing; `form_start` has no server-observable moment at all. |
130
+ | Element visibility | `IntersectionObserver` state, internal to the container. |
131
+ | Timers | Wall-clock dwell time in the browser session. |
132
+ | `gtm.js`, `gtm.dom`, `gtm.load`, history change | Generated by the container itself. |
133
+
134
+ There is nothing to configure here, and nothing is lost: the container stays loaded in the browser
135
+ even when server-side tagging is on, so its own triggers keep handling all of them. The backend
136
+ ingest rejects any event name outside the relay-eligible list, so a crafted request cannot give a
137
+ client-only event a server path either.
138
+
139
+ ## Server-side tagging
140
+
141
+ With `Server-side tagging` on and a server container URL configured, the ten events above stop being
142
+ pushed to the browser `dataLayer` and are delivered from the platform's backend instead. Each
143
+ logical event is reported exactly once — never both ways.
144
+
145
+ > **This *moves* those events out of the web container.** Tags that triggered on them in the web
146
+ > container must be rebuilt in the **server** container. Everything configured inside the web
147
+ > container that observes the browser — scroll depth, link clicks, file downloads, form interaction,
148
+ > element visibility, timers — is unaffected and keeps firing.
149
+
150
+ A blank `Server container URL` keeps the channel on the browser path even with the switch on, so
151
+ turning the switch on first is harmless.
152
+
153
+ ### How delivery works
154
+
155
+ The storefront posts to `POST /api/v1/storefront/google-tag-manager/collect`, which is a **pure
156
+ producer**: it validates the batch and enqueues one job per event onto the durable BullMQ queue
157
+ `google_tag_manager.ss.relay`, then answers `202`. No outbound call happens inside the shopper's
158
+ request, so a slow or failing container can never affect the shop.
159
+
160
+ A separable worker (co-located in the API process unless `BACKEND_ROLE=api`, in which case only the
161
+ `worker` process runs it) posts each event to your container and retries on failure: eight attempts
162
+ with exponential backoff from one second. Exhausted deliveries are retained as failed jobs so they
163
+ are observable rather than silently dropped.
164
+
165
+ ### The request your container receives
166
+
167
+ ```
168
+ POST {server_container_url}{server_ingest_path}
169
+ Content-Type: application/json
170
+ ```
171
+
172
+ ```json
173
+ {
174
+ "event_name": "purchase",
175
+ "client_id": "1234567890.1754006400",
176
+ "event_id": "6b1f0ec4-0000-4000-8000-000000000001",
177
+ "page_location": "https://shop.example.com/checkout/thank-you",
178
+ "page_referrer": "https://shop.example.com/checkout",
179
+ "page_title": "Thank you",
180
+ "language": "pl",
181
+ "consent": { "analytics_storage": "granted" },
182
+ "ip_override": "203.0.113.7",
183
+ "user_agent": "Mozilla/5.0 ...",
184
+ "transaction_id": "ORD-2026-000123",
185
+ "value": 1249.0,
186
+ "currency": "PLN",
187
+ "items": "[{\"item_id\":\"SKU-9\",\"item_name\":\"Widget 9\",\"price\":249,\"quantity\":5}]",
188
+ "gtm_event_id": "6b1f0ec4-0000-4000-8000-000000000001"
189
+ }
190
+ ```
191
+
192
+ The event's own parameters are flattened at the top level. `client_id`, `ip_override` and
193
+ `user_agent` deliberately reuse the GA4 Measurement Protocol field names, which server-side GTM's
194
+ clients already recognise, so you write no translation layer. No credentials are sent: an sGTM
195
+ ingest endpoint is a public collection endpoint by design.
196
+
197
+ ### What you have to do on your side
198
+
199
+ 1. **Claim the configured request path.** Google's **Data Client** claims `/data` by default; a
200
+ custom client can claim any path, in which case set `Server container ingest path` to match.
201
+ 2. **Rebuild the tags** that triggered on the platform's commerce events, in the server container.
202
+ Those events no longer reach the web container.
203
+ 3. **Parse `items` as JSON** — it arrives as a string, not an array.
204
+ 4. **Deduplicate on `event_id`** (also present as `gtm_event_id`). It is stable across retries, so a
205
+ redelivery carries the same value.
206
+ 5. **Handle a missing IP and user agent.** When the visitor denied consent, `ip_override` and
207
+ `user_agent` are omitted entirely, so geo and device enrichment is not always possible.
208
+
209
+ ### What the relay never carries
210
+
211
+ Only the documented commerce parameters, page context, client id, consent state and event id. No
212
+ `user_id`, no e-mail address, no organization id, no postal address, no hashed identifiers, and
213
+ never a file attachment. Enhanced conversions and user-id stitching are deliberately out of scope —
214
+ add whatever your container needs, in your container.
215
+
216
+ ## Permissions
217
+
218
+ The module declares **no permission codes of its own**. Its configuration is reached through the
219
+ generic Settings screen and is therefore gated by the core `settings:read` / `settings:write`
220
+ permissions, and audited by the Settings module.
221
+
222
+ ## Not yet implemented
223
+
224
+ - **Serving `gtm.js` first-party from the server container.** Attractive for cookie lifetime and
225
+ ad-blocker resilience, but a mis-provisioned server container would turn a delivery setting into a
226
+ total tracking outage. It needs its own setting and its own acceptance test.
227
+ - **`user_id` and enhanced conversions in the relay** — each needs its own consent and hashing
228
+ analysis.
package/i18n/en.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "actions.openGoogleTagManager.label": "Google Tag Manager",
3
+ "actions.openGoogleTagManager.description": "Configure the GTM container and server-side tagging per sales channel"
4
+ }
package/i18n/pl.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "actions.openGoogleTagManager.label": "Google Tag Manager",
3
+ "actions.openGoogleTagManager.description": "Skonfiguruj kontener GTM i tagowanie server-side dla kanału sprzedaży"
4
+ }