@shware/analytics 8.2.0 → 8.3.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.
- package/README.md +63 -2
- package/dist/click-id/index.cjs +57 -3
- package/dist/click-id/index.cjs.map +1 -1
- package/dist/click-id/index.d.cts +16 -1
- package/dist/click-id/index.d.cts.map +1 -1
- package/dist/click-id/index.d.mts +16 -1
- package/dist/click-id/index.d.mts.map +1 -1
- package/dist/click-id/index.mjs +54 -4
- package/dist/click-id/index.mjs.map +1 -1
- package/dist/server/google-data-manager.cjs +2 -0
- package/dist/server/google-data-manager.cjs.map +1 -1
- package/dist/server/google-data-manager.d.cts.map +1 -1
- package/dist/server/google-data-manager.d.mts.map +1 -1
- package/dist/server/google-data-manager.mjs +2 -0
- package/dist/server/google-data-manager.mjs.map +1 -1
- package/dist/server/index.cjs +8 -0
- package/dist/server/index.d.cts +5 -4
- package/dist/server/index.d.mts +5 -4
- package/dist/server/index.mjs +5 -4
- package/dist/server/microsoft-conversions-api.cjs +171 -0
- package/dist/server/microsoft-conversions-api.cjs.map +1 -0
- package/dist/server/microsoft-conversions-api.d.cts +117 -0
- package/dist/server/microsoft-conversions-api.d.cts.map +1 -0
- package/dist/server/microsoft-conversions-api.d.mts +117 -0
- package/dist/server/microsoft-conversions-api.d.mts.map +1 -0
- package/dist/server/microsoft-conversions-api.mjs +168 -0
- package/dist/server/microsoft-conversions-api.mjs.map +1 -0
- package/dist/tanstack/middleware.cjs +2 -1
- package/dist/tanstack/middleware.cjs.map +1 -1
- package/dist/tanstack/middleware.d.cts +2 -1
- package/dist/tanstack/middleware.d.cts.map +1 -1
- package/dist/tanstack/middleware.d.mts +2 -1
- package/dist/tanstack/middleware.d.mts.map +1 -1
- package/dist/tanstack/middleware.mjs +2 -1
- package/dist/tanstack/middleware.mjs.map +1 -1
- package/dist/third-parties/index.cjs +5 -0
- package/dist/third-parties/index.d.cts +3 -1
- package/dist/third-parties/index.d.mts +3 -1
- package/dist/third-parties/index.mjs +2 -1
- package/dist/third-parties/microsoft-uet.cjs +119 -0
- package/dist/third-parties/microsoft-uet.cjs.map +1 -0
- package/dist/third-parties/microsoft-uet.d.cts +70 -0
- package/dist/third-parties/microsoft-uet.d.cts.map +1 -0
- package/dist/third-parties/microsoft-uet.d.mts +70 -0
- package/dist/third-parties/microsoft-uet.d.mts.map +1 -0
- package/dist/third-parties/microsoft-uet.mjs +115 -0
- package/dist/third-parties/microsoft-uet.mjs.map +1 -0
- package/dist/third-parties/openai-pixel.cjs +2 -7
- package/dist/third-parties/openai-pixel.cjs.map +1 -1
- package/dist/third-parties/openai-pixel.d.cts.map +1 -1
- package/dist/third-parties/openai-pixel.d.mts.map +1 -1
- package/dist/third-parties/openai-pixel.mjs +1 -6
- package/dist/third-parties/openai-pixel.mjs.map +1 -1
- package/dist/track/uetq.cjs +80 -0
- package/dist/track/uetq.cjs.map +1 -0
- package/dist/track/uetq.d.cts +111 -0
- package/dist/track/uetq.d.cts.map +1 -0
- package/dist/track/uetq.d.mts +111 -0
- package/dist/track/uetq.d.mts.map +1 -0
- package/dist/track/uetq.mjs +78 -0
- package/dist/track/uetq.mjs.map +1 -0
- package/dist/utils/sha256.cjs +19 -0
- package/dist/utils/sha256.cjs.map +1 -0
- package/dist/utils/sha256.d.cts +13 -0
- package/dist/utils/sha256.d.cts.map +1 -0
- package/dist/utils/sha256.d.mts +13 -0
- package/dist/utils/sha256.d.mts.map +1 -0
- package/dist/utils/sha256.mjs +18 -0
- package/dist/utils/sha256.mjs.map +1 -0
- package/dist/web/index.cjs +1 -1
- package/dist/web/index.cjs.map +1 -1
- package/dist/web/index.d.cts.map +1 -1
- package/dist/web/index.d.mts.map +1 -1
- package/dist/web/index.mjs +2 -2
- package/dist/web/index.mjs.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"google-data-manager.cjs","names":["createHash","resolveActionSource","IGNORED_EVENTS","fetch"],"sources":["../../src/server/google-data-manager.ts"],"sourcesContent":["/**\n * Google Ads conversion sender over the Data Manager API (`events:ingest`) — Google's\n * counterpart to Meta's Conversions API, and the mandated successor to the Google Ads API's\n * `UploadClickConversions`, which stopped accepting new adopters on 2026-06-15. Same shape as\n * the other senders here: a pure per-event builder, a never-throws `sendEvents`, credentials\n * in headers and never in a URL or a log line.\n *\n * Google's recommended deployment is *hybrid* (\"boost your tag with additional data sources\"):\n * this upload attaches to the SAME conversion action the gtag tag reports to, matched by\n * `transactionId` — a matched pair collapses into one conversion with the server data winning,\n * an unmatched upload is a recovered conversion. Both channels here are fed from the same\n * `track()` call, so the ids agree by construction. `transactionId` is therefore required in\n * that mode; this sender always sends one (the event id when there is no natural order id).\n *\n * Auth is a plain OAuth2 access token with the `datamanager` scope — no Google Ads developer\n * token, no API Center approval. The caller exchanges its stored refresh token for the access\n * token (one POST to oauth2.googleapis.com) and refreshes it however it likes; this module\n * stays out of the OAuth business, exactly as it stays out of Meta's.\n *\n * https://developers.google.com/data-manager/api/reference/rest/v1/events/ingest\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade/field-mappings\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\n\nconst ENDPOINT = 'https://datamanager.googleapis.com/v1/events:ingest';\n\n/**\n * Event name → conversion action id, mirroring `LinkedinConversionConfig`: only configured\n * events upload, everything else is skipped silently. In the hybrid setup this is the id of\n * the SAME conversion action the gtag tag reports to.\n */\nexport type GoogleAdsConversionConfig = Record<Lowercase<string>, number | string>;\n\nexport interface GoogleAdsAuth {\n /** OAuth2 access token with the `datamanager` scope. Short-lived; the caller refreshes it. */\n accessToken: string;\n /** The Google Ads account the conversion actions live in. Dashes are tolerated: '123-456-7890'. */\n operatingAccountId: string;\n /** The manager account the OAuth user accesses through, when access is delegated. */\n loginAccountId?: string;\n}\n\n/**\n * EEA/UK/CH consent, applied request-wide. Google drops EEA conversions without granted\n * `adUserData` from bidding, so a host with consent signals should pass them.\n */\nexport interface GoogleAdsConsent {\n adUserData?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n adPersonalization?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n}\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n}\n\n/**\n * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the\n * username's dots and everything from '+' on are removed; for every other domain they are kept.\n * https://developers.google.com/data-manager/api/devguides/format-data\n */\nexport function normalizeEmail(input: string): string {\n const email = input.trim().toLowerCase();\n const at = email.lastIndexOf('@');\n if (at === -1) return email;\n let username = email.slice(0, at);\n const domain = email.slice(at + 1);\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n username = username.replace(/\\./g, '').split('+', 1)[0] ?? '';\n }\n return `${username}@${domain}`;\n}\n\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\nfunction normalizeCurrency(input: unknown): string {\n if (typeof input !== 'string') return 'USD';\n const currency = input.trim().toUpperCase();\n return /^[A-Z]{3}$/.test(currency) ? currency : 'USD';\n}\n\n/** At most 10 userIdentifiers per event, per the API's hard limit. */\nconst MAX_USER_IDENTIFIERS = 10;\n\n/** Hashes are hex strings, declared by `encoding: 'HEX'` on the request. */\nfunction getUserIdentifiers(data: UserProvidedData): Record<string, string>[] {\n const identifiers: Record<string, string>[] = [];\n const emails = Array.isArray(data.email) ? data.email : data.email ? [data.email] : [];\n for (const email of emails) {\n identifiers.push({ emailAddress: sha256(normalizeEmail(email)) });\n }\n const phones = Array.isArray(data.phone_number)\n ? data.phone_number\n : data.phone_number\n ? [data.phone_number]\n : [];\n for (const phone of phones) {\n // Phones are already E.164 by the schema ('+' then digits); hash after a defensive trim.\n identifiers.push({ phoneNumber: sha256(phone.trim()) });\n }\n return identifiers.slice(0, MAX_USER_IDENTIFIERS);\n}\n\n/** One entry of the `events` array. Field names are the REST (camelCase) spellings. */\nexport interface DataManagerEvent {\n destinationReferences: string[];\n transactionId: string;\n /** RFC 3339 — `created_at` as stored, unlike the legacy API's bespoke format. */\n eventTimestamp: string;\n eventSource: 'WEB' | 'APP' | 'OTHER';\n conversionValue: number;\n currency: string;\n adIdentifiers?: { gclid?: string; gbraid?: string; wbraid?: string };\n userData?: { userIdentifiers: Record<string, string>[] };\n}\n\n/**\n * Builds one Event, or undefined when no conversion action is configured for the event's name.\n * At most one click identifier is sent, gclid first: it is the deterministic per-click id,\n * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike\n * the legacy upload, an event with no click id at all is still worth sending when it carries\n * user identifiers — that is the enhanced-conversions match path.\n */\nexport function getDataManagerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n config: GoogleAdsConversionConfig,\n data: UserProvidedData = {}\n): DataManagerEvent | undefined {\n const action = config[event.name as Lowercase<string>];\n if (!action) return undefined;\n\n const source = resolveActionSource(event.platform);\n const dmEvent: DataManagerEvent = {\n destinationReferences: [String(action)],\n // The hybrid-mode match key against the gtag tag's transaction_id: same track() call feeds\n // both channels, so they agree by construction for purchase-shaped events. The event id\n // covers conversions with no natural order id — but the gtag tracker only sends a\n // transaction_id when the properties carry one, so on a HYBRID action a non-purchase event\n // reported by both channels never matches and counts twice. Attach such events to a\n // standalone (API-only) conversion action instead.\n transactionId:\n typeof event.properties?.transaction_id === 'string'\n ? event.properties.transaction_id\n : event.id,\n eventTimestamp: new Date(event.created_at).toISOString(),\n eventSource: source === 'app' ? 'APP' : source === 'web' ? 'WEB' : 'OTHER',\n // Both mandatory, like the LinkedIn sender's defaults: 0 USD is Google's own convention\n // for value-less goals. The API has no partial-failure mode, so a malformed currency would\n // cost the whole batch — anything that isn't a 3-letter code falls back instead.\n conversionValue: typeof event.properties?.value === 'number' ? event.properties.value : 0,\n currency: normalizeCurrency(event.properties?.currency),\n };\n\n const { gclid, gbraid, wbraid } = event.tags;\n if (gclid) dmEvent.adIdentifiers = { gclid };\n else if (gbraid) dmEvent.adIdentifiers = { gbraid };\n else if (wbraid) dmEvent.adIdentifiers = { wbraid };\n\n const userIdentifiers = getUserIdentifiers(data);\n if (userIdentifiers.length > 0) dmEvent.userData = { userIdentifiers };\n\n // With neither a click id nor an identifier Google has nothing to match on; skip.\n if (!dmEvent.adIdentifiers && !dmEvent.userData) return undefined;\n\n return dmEvent;\n}\n\nexport interface DataManagerResponse {\n requestId?: string;\n}\n\n/** The API takes at most 2000 events per request. */\nconst MAX_EVENTS_PER_REQUEST = 2000;\n\n/**\n * Uploads the configured conversions from a batch of events. Never throws: an unusable event\n * is skipped and a failed request is logged and swallowed, like every other sender here. The\n * Data Manager API has no partial-failure mode — a rejected request costs the whole batch, so\n * the builder is strict about only emitting events the API can accept. A batch beyond the\n * 2000-event request cap goes out as sequential requests; the first response is returned.\n */\nexport async function sendEvents(\n auth: GoogleAdsAuth,\n config: GoogleAdsConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: GoogleAdsConversionsOptions = {}\n): Promise<DataManagerResponse | undefined> {\n const dmEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getDataManagerEvent(event, config, data))\n .filter((dmEvent): dmEvent is DataManagerEvent => dmEvent !== undefined);\n if (dmEvents.length === 0) return undefined;\n\n const operatingAccount = {\n accountType: 'GOOGLE_ADS',\n accountId: auth.operatingAccountId.replace(/-/g, ''),\n };\n // One destination per conversion action present in the batch; each event points at its own\n // through destinationReferences, so mixed-action batches go out as one request.\n const loginAccount = auth.loginAccountId\n ? { accountType: 'GOOGLE_ADS', accountId: auth.loginAccountId.replace(/-/g, '') }\n : undefined;\n const actions = Array.from(new Set(dmEvents.flatMap((dmEvent) => dmEvent.destinationReferences)));\n const destinations = actions.map((action) => {\n const destination: Record<string, unknown> = {\n reference: action,\n operatingAccount,\n productDestinationId: action,\n };\n if (loginAccount) destination.loginAccount = loginAccount;\n return destination;\n });\n\n let firstResponse: DataManagerResponse | undefined;\n for (let offset = 0; offset < dmEvents.length; offset += MAX_EVENTS_PER_REQUEST) {\n const chunk = dmEvents.slice(offset, offset + MAX_EVENTS_PER_REQUEST);\n const body: Record<string, unknown> = {\n destinations,\n events: chunk,\n validateOnly: options.validateOnly ?? false,\n };\n if (chunk.some((dmEvent) => dmEvent.userData)) {\n body.encoding = 'HEX';\n }\n if (options.consent) {\n body.consent = options.consent;\n }\n\n try {\n const response = await fetch(ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${auth.accessToken}`,\n },\n body: JSON.stringify(body),\n });\n if (response.ok) {\n firstResponse ??= (await response.json()) as DataManagerResponse;\n continue;\n }\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Google Ads conversions, status: ${status}, body: ${message}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Google Ads conversions, network error: ${message}`);\n }\n }\n return firstResponse;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"google-data-manager.cjs","names":["createHash","resolveActionSource","IGNORED_EVENTS","fetch"],"sources":["../../src/server/google-data-manager.ts"],"sourcesContent":["/**\n * Google Ads conversion sender over the Data Manager API (`events:ingest`) — Google's\n * counterpart to Meta's Conversions API, and the mandated successor to the Google Ads API's\n * `UploadClickConversions`, which stopped accepting new adopters on 2026-06-15. Same shape as\n * the other senders here: a pure per-event builder, a never-throws `sendEvents`, credentials\n * in headers and never in a URL or a log line.\n *\n * Google's recommended deployment is *hybrid* (\"boost your tag with additional data sources\"):\n * this upload attaches to the SAME conversion action the gtag tag reports to, matched by\n * `transactionId` — a matched pair collapses into one conversion with the server data winning,\n * an unmatched upload is a recovered conversion. Both channels here are fed from the same\n * `track()` call, so the ids agree by construction. `transactionId` is therefore required in\n * that mode; this sender always sends one (the event id when there is no natural order id).\n *\n * Auth is a plain OAuth2 access token with the `datamanager` scope — no Google Ads developer\n * token, no API Center approval. The caller exchanges its stored refresh token for the access\n * token (one POST to oauth2.googleapis.com) and refreshes it however it likes; this module\n * stays out of the OAuth business, exactly as it stays out of Meta's.\n *\n * https://developers.google.com/data-manager/api/reference/rest/v1/events/ingest\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade/field-mappings\n * https://support.google.com/google-ads/answer/16542291 (hybrid: \"boost your tag with\n * additional data sources\" — the transaction-id matching contract this sender relies on)\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\n\nconst ENDPOINT = 'https://datamanager.googleapis.com/v1/events:ingest';\n\n/**\n * Event name → conversion action id, mirroring `LinkedinConversionConfig`: only configured\n * events upload, everything else is skipped silently. In the hybrid setup this is the id of\n * the SAME conversion action the gtag tag reports to.\n */\nexport type GoogleAdsConversionConfig = Record<Lowercase<string>, number | string>;\n\nexport interface GoogleAdsAuth {\n /** OAuth2 access token with the `datamanager` scope. Short-lived; the caller refreshes it. */\n accessToken: string;\n /** The Google Ads account the conversion actions live in. Dashes are tolerated: '123-456-7890'. */\n operatingAccountId: string;\n /** The manager account the OAuth user accesses through, when access is delegated. */\n loginAccountId?: string;\n}\n\n/**\n * EEA/UK/CH consent, applied request-wide. Google drops EEA conversions without granted\n * `adUserData` from bidding, so a host with consent signals should pass them.\n */\nexport interface GoogleAdsConsent {\n adUserData?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n adPersonalization?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n}\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n}\n\n/**\n * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the\n * username's dots and everything from '+' on are removed; for every other domain they are kept.\n * https://developers.google.com/data-manager/api/devguides/format-data\n */\nexport function normalizeEmail(input: string): string {\n const email = input.trim().toLowerCase();\n const at = email.lastIndexOf('@');\n if (at === -1) return email;\n let username = email.slice(0, at);\n const domain = email.slice(at + 1);\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n username = username.replace(/\\./g, '').split('+', 1)[0] ?? '';\n }\n return `${username}@${domain}`;\n}\n\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\nfunction normalizeCurrency(input: unknown): string {\n if (typeof input !== 'string') return 'USD';\n const currency = input.trim().toUpperCase();\n return /^[A-Z]{3}$/.test(currency) ? currency : 'USD';\n}\n\n/** At most 10 userIdentifiers per event, per the API's hard limit. */\nconst MAX_USER_IDENTIFIERS = 10;\n\n/** Hashes are hex strings, declared by `encoding: 'HEX'` on the request. */\nfunction getUserIdentifiers(data: UserProvidedData): Record<string, string>[] {\n const identifiers: Record<string, string>[] = [];\n const emails = Array.isArray(data.email) ? data.email : data.email ? [data.email] : [];\n for (const email of emails) {\n identifiers.push({ emailAddress: sha256(normalizeEmail(email)) });\n }\n const phones = Array.isArray(data.phone_number)\n ? data.phone_number\n : data.phone_number\n ? [data.phone_number]\n : [];\n for (const phone of phones) {\n // Phones are already E.164 by the schema ('+' then digits); hash after a defensive trim.\n identifiers.push({ phoneNumber: sha256(phone.trim()) });\n }\n return identifiers.slice(0, MAX_USER_IDENTIFIERS);\n}\n\n/** One entry of the `events` array. Field names are the REST (camelCase) spellings. */\nexport interface DataManagerEvent {\n destinationReferences: string[];\n transactionId: string;\n /** RFC 3339 — `created_at` as stored, unlike the legacy API's bespoke format. */\n eventTimestamp: string;\n eventSource: 'WEB' | 'APP' | 'OTHER';\n conversionValue: number;\n currency: string;\n adIdentifiers?: { gclid?: string; gbraid?: string; wbraid?: string };\n userData?: { userIdentifiers: Record<string, string>[] };\n}\n\n/**\n * Builds one Event, or undefined when no conversion action is configured for the event's name.\n * At most one click identifier is sent, gclid first: it is the deterministic per-click id,\n * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike\n * the legacy upload, an event with no click id at all is still worth sending when it carries\n * user identifiers — that is the enhanced-conversions match path.\n */\nexport function getDataManagerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n config: GoogleAdsConversionConfig,\n data: UserProvidedData = {}\n): DataManagerEvent | undefined {\n const action = config[event.name as Lowercase<string>];\n if (!action) return undefined;\n\n const source = resolveActionSource(event.platform);\n const dmEvent: DataManagerEvent = {\n destinationReferences: [String(action)],\n // The hybrid-mode match key against the gtag tag's transaction_id: same track() call feeds\n // both channels, so they agree by construction for purchase-shaped events. The event id\n // covers conversions with no natural order id — but the gtag tracker only sends a\n // transaction_id when the properties carry one, so on a HYBRID action a non-purchase event\n // reported by both channels never matches and counts twice. Attach such events to a\n // standalone (API-only) conversion action instead.\n transactionId:\n typeof event.properties?.transaction_id === 'string'\n ? event.properties.transaction_id\n : event.id,\n eventTimestamp: new Date(event.created_at).toISOString(),\n eventSource: source === 'app' ? 'APP' : source === 'web' ? 'WEB' : 'OTHER',\n // Both mandatory, like the LinkedIn sender's defaults: 0 USD is Google's own convention\n // for value-less goals. The API has no partial-failure mode, so a malformed currency would\n // cost the whole batch — anything that isn't a 3-letter code falls back instead.\n conversionValue: typeof event.properties?.value === 'number' ? event.properties.value : 0,\n currency: normalizeCurrency(event.properties?.currency),\n };\n\n const { gclid, gbraid, wbraid } = event.tags;\n if (gclid) dmEvent.adIdentifiers = { gclid };\n else if (gbraid) dmEvent.adIdentifiers = { gbraid };\n else if (wbraid) dmEvent.adIdentifiers = { wbraid };\n\n const userIdentifiers = getUserIdentifiers(data);\n if (userIdentifiers.length > 0) dmEvent.userData = { userIdentifiers };\n\n // With neither a click id nor an identifier Google has nothing to match on; skip.\n if (!dmEvent.adIdentifiers && !dmEvent.userData) return undefined;\n\n return dmEvent;\n}\n\nexport interface DataManagerResponse {\n requestId?: string;\n}\n\n/** The API takes at most 2000 events per request. */\nconst MAX_EVENTS_PER_REQUEST = 2000;\n\n/**\n * Uploads the configured conversions from a batch of events. Never throws: an unusable event\n * is skipped and a failed request is logged and swallowed, like every other sender here. The\n * Data Manager API has no partial-failure mode — a rejected request costs the whole batch, so\n * the builder is strict about only emitting events the API can accept. A batch beyond the\n * 2000-event request cap goes out as sequential requests; the first response is returned.\n */\nexport async function sendEvents(\n auth: GoogleAdsAuth,\n config: GoogleAdsConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: GoogleAdsConversionsOptions = {}\n): Promise<DataManagerResponse | undefined> {\n const dmEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getDataManagerEvent(event, config, data))\n .filter((dmEvent): dmEvent is DataManagerEvent => dmEvent !== undefined);\n if (dmEvents.length === 0) return undefined;\n\n const operatingAccount = {\n accountType: 'GOOGLE_ADS',\n accountId: auth.operatingAccountId.replace(/-/g, ''),\n };\n // One destination per conversion action present in the batch; each event points at its own\n // through destinationReferences, so mixed-action batches go out as one request.\n const loginAccount = auth.loginAccountId\n ? { accountType: 'GOOGLE_ADS', accountId: auth.loginAccountId.replace(/-/g, '') }\n : undefined;\n const actions = Array.from(new Set(dmEvents.flatMap((dmEvent) => dmEvent.destinationReferences)));\n const destinations = actions.map((action) => {\n const destination: Record<string, unknown> = {\n reference: action,\n operatingAccount,\n productDestinationId: action,\n };\n if (loginAccount) destination.loginAccount = loginAccount;\n return destination;\n });\n\n let firstResponse: DataManagerResponse | undefined;\n for (let offset = 0; offset < dmEvents.length; offset += MAX_EVENTS_PER_REQUEST) {\n const chunk = dmEvents.slice(offset, offset + MAX_EVENTS_PER_REQUEST);\n const body: Record<string, unknown> = {\n destinations,\n events: chunk,\n validateOnly: options.validateOnly ?? false,\n };\n if (chunk.some((dmEvent) => dmEvent.userData)) {\n body.encoding = 'HEX';\n }\n if (options.consent) {\n body.consent = options.consent;\n }\n\n try {\n const response = await fetch(ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${auth.accessToken}`,\n },\n body: JSON.stringify(body),\n });\n if (response.ok) {\n firstResponse ??= (await response.json()) as DataManagerResponse;\n continue;\n }\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Google Ads conversions, status: ${status}, body: ${message}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Google Ads conversions, network error: ${message}`);\n }\n }\n return firstResponse;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"google-data-manager.d.cts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"google-data-manager.d.cts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;KAqCY,4BAA4B,OAAO;UAE9B;;EAEf;;EAEA;;EAEA;;;;;;UAOe;EACf;EACA;;UAGe;;EAEf;EACA,UAAU;;;;;;;iBAQI,eAAe;;UA2Cd;EACf;EACA;;EAEA;EACA;EACA;EACA;EACA;IAAkB;IAAgB;IAAiB;;EACnD;IAAa,iBAAiB;;;;;;;;;;iBAUhB,oBAEd,OAAO,iBACP,QAAQ,2BACR,OAAM,mBACL;UAwCc;EACf;;;;;;;;;iBAaoB,WACpB,MAAM,eACN,QAAQ,2BAER,QAAQ,mBACR,OAAM,kBACN,UAAS,8BACR,QAAQ"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"google-data-manager.d.mts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;
|
|
1
|
+
{"version":3,"file":"google-data-manager.d.mts","names":[],"sources":["../../src/server/google-data-manager.ts"],"mappings":";;;;;;;KAqCY,4BAA4B,OAAO;UAE9B;;EAEf;;EAEA;;EAEA;;;;;;UAOe;EACf;EACA;;UAGe;;EAEf;EACA,UAAU;;;;;;;iBAQI,eAAe;;UA2Cd;EACf;EACA;;EAEA;EACA;EACA;EACA;EACA;IAAkB;IAAgB;IAAiB;;EACnD;IAAa,iBAAiB;;;;;;;;;;iBAUhB,oBAEd,OAAO,iBACP,QAAQ,2BACR,OAAM,mBACL;UAwCc;EACf;;;;;;;;;iBAaoB,WACpB,MAAM,eACN,QAAQ,2BAER,QAAQ,mBACR,OAAM,kBACN,UAAS,8BACR,QAAQ"}
|
|
@@ -24,6 +24,8 @@ import { createHash } from "node:crypto";
|
|
|
24
24
|
*
|
|
25
25
|
* https://developers.google.com/data-manager/api/reference/rest/v1/events/ingest
|
|
26
26
|
* https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade/field-mappings
|
|
27
|
+
* https://support.google.com/google-ads/answer/16542291 (hybrid: "boost your tag with
|
|
28
|
+
* additional data sources" — the transaction-id matching contract this sender relies on)
|
|
27
29
|
*/
|
|
28
30
|
const ENDPOINT = "https://datamanager.googleapis.com/v1/events:ingest";
|
|
29
31
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"google-data-manager.mjs","names":[],"sources":["../../src/server/google-data-manager.ts"],"sourcesContent":["/**\n * Google Ads conversion sender over the Data Manager API (`events:ingest`) — Google's\n * counterpart to Meta's Conversions API, and the mandated successor to the Google Ads API's\n * `UploadClickConversions`, which stopped accepting new adopters on 2026-06-15. Same shape as\n * the other senders here: a pure per-event builder, a never-throws `sendEvents`, credentials\n * in headers and never in a URL or a log line.\n *\n * Google's recommended deployment is *hybrid* (\"boost your tag with additional data sources\"):\n * this upload attaches to the SAME conversion action the gtag tag reports to, matched by\n * `transactionId` — a matched pair collapses into one conversion with the server data winning,\n * an unmatched upload is a recovered conversion. Both channels here are fed from the same\n * `track()` call, so the ids agree by construction. `transactionId` is therefore required in\n * that mode; this sender always sends one (the event id when there is no natural order id).\n *\n * Auth is a plain OAuth2 access token with the `datamanager` scope — no Google Ads developer\n * token, no API Center approval. The caller exchanges its stored refresh token for the access\n * token (one POST to oauth2.googleapis.com) and refreshes it however it likes; this module\n * stays out of the OAuth business, exactly as it stays out of Meta's.\n *\n * https://developers.google.com/data-manager/api/reference/rest/v1/events/ingest\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade/field-mappings\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\n\nconst ENDPOINT = 'https://datamanager.googleapis.com/v1/events:ingest';\n\n/**\n * Event name → conversion action id, mirroring `LinkedinConversionConfig`: only configured\n * events upload, everything else is skipped silently. In the hybrid setup this is the id of\n * the SAME conversion action the gtag tag reports to.\n */\nexport type GoogleAdsConversionConfig = Record<Lowercase<string>, number | string>;\n\nexport interface GoogleAdsAuth {\n /** OAuth2 access token with the `datamanager` scope. Short-lived; the caller refreshes it. */\n accessToken: string;\n /** The Google Ads account the conversion actions live in. Dashes are tolerated: '123-456-7890'. */\n operatingAccountId: string;\n /** The manager account the OAuth user accesses through, when access is delegated. */\n loginAccountId?: string;\n}\n\n/**\n * EEA/UK/CH consent, applied request-wide. Google drops EEA conversions without granted\n * `adUserData` from bidding, so a host with consent signals should pass them.\n */\nexport interface GoogleAdsConsent {\n adUserData?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n adPersonalization?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n}\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n}\n\n/**\n * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the\n * username's dots and everything from '+' on are removed; for every other domain they are kept.\n * https://developers.google.com/data-manager/api/devguides/format-data\n */\nexport function normalizeEmail(input: string): string {\n const email = input.trim().toLowerCase();\n const at = email.lastIndexOf('@');\n if (at === -1) return email;\n let username = email.slice(0, at);\n const domain = email.slice(at + 1);\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n username = username.replace(/\\./g, '').split('+', 1)[0] ?? '';\n }\n return `${username}@${domain}`;\n}\n\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\nfunction normalizeCurrency(input: unknown): string {\n if (typeof input !== 'string') return 'USD';\n const currency = input.trim().toUpperCase();\n return /^[A-Z]{3}$/.test(currency) ? currency : 'USD';\n}\n\n/** At most 10 userIdentifiers per event, per the API's hard limit. */\nconst MAX_USER_IDENTIFIERS = 10;\n\n/** Hashes are hex strings, declared by `encoding: 'HEX'` on the request. */\nfunction getUserIdentifiers(data: UserProvidedData): Record<string, string>[] {\n const identifiers: Record<string, string>[] = [];\n const emails = Array.isArray(data.email) ? data.email : data.email ? [data.email] : [];\n for (const email of emails) {\n identifiers.push({ emailAddress: sha256(normalizeEmail(email)) });\n }\n const phones = Array.isArray(data.phone_number)\n ? data.phone_number\n : data.phone_number\n ? [data.phone_number]\n : [];\n for (const phone of phones) {\n // Phones are already E.164 by the schema ('+' then digits); hash after a defensive trim.\n identifiers.push({ phoneNumber: sha256(phone.trim()) });\n }\n return identifiers.slice(0, MAX_USER_IDENTIFIERS);\n}\n\n/** One entry of the `events` array. Field names are the REST (camelCase) spellings. */\nexport interface DataManagerEvent {\n destinationReferences: string[];\n transactionId: string;\n /** RFC 3339 — `created_at` as stored, unlike the legacy API's bespoke format. */\n eventTimestamp: string;\n eventSource: 'WEB' | 'APP' | 'OTHER';\n conversionValue: number;\n currency: string;\n adIdentifiers?: { gclid?: string; gbraid?: string; wbraid?: string };\n userData?: { userIdentifiers: Record<string, string>[] };\n}\n\n/**\n * Builds one Event, or undefined when no conversion action is configured for the event's name.\n * At most one click identifier is sent, gclid first: it is the deterministic per-click id,\n * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike\n * the legacy upload, an event with no click id at all is still worth sending when it carries\n * user identifiers — that is the enhanced-conversions match path.\n */\nexport function getDataManagerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n config: GoogleAdsConversionConfig,\n data: UserProvidedData = {}\n): DataManagerEvent | undefined {\n const action = config[event.name as Lowercase<string>];\n if (!action) return undefined;\n\n const source = resolveActionSource(event.platform);\n const dmEvent: DataManagerEvent = {\n destinationReferences: [String(action)],\n // The hybrid-mode match key against the gtag tag's transaction_id: same track() call feeds\n // both channels, so they agree by construction for purchase-shaped events. The event id\n // covers conversions with no natural order id — but the gtag tracker only sends a\n // transaction_id when the properties carry one, so on a HYBRID action a non-purchase event\n // reported by both channels never matches and counts twice. Attach such events to a\n // standalone (API-only) conversion action instead.\n transactionId:\n typeof event.properties?.transaction_id === 'string'\n ? event.properties.transaction_id\n : event.id,\n eventTimestamp: new Date(event.created_at).toISOString(),\n eventSource: source === 'app' ? 'APP' : source === 'web' ? 'WEB' : 'OTHER',\n // Both mandatory, like the LinkedIn sender's defaults: 0 USD is Google's own convention\n // for value-less goals. The API has no partial-failure mode, so a malformed currency would\n // cost the whole batch — anything that isn't a 3-letter code falls back instead.\n conversionValue: typeof event.properties?.value === 'number' ? event.properties.value : 0,\n currency: normalizeCurrency(event.properties?.currency),\n };\n\n const { gclid, gbraid, wbraid } = event.tags;\n if (gclid) dmEvent.adIdentifiers = { gclid };\n else if (gbraid) dmEvent.adIdentifiers = { gbraid };\n else if (wbraid) dmEvent.adIdentifiers = { wbraid };\n\n const userIdentifiers = getUserIdentifiers(data);\n if (userIdentifiers.length > 0) dmEvent.userData = { userIdentifiers };\n\n // With neither a click id nor an identifier Google has nothing to match on; skip.\n if (!dmEvent.adIdentifiers && !dmEvent.userData) return undefined;\n\n return dmEvent;\n}\n\nexport interface DataManagerResponse {\n requestId?: string;\n}\n\n/** The API takes at most 2000 events per request. */\nconst MAX_EVENTS_PER_REQUEST = 2000;\n\n/**\n * Uploads the configured conversions from a batch of events. Never throws: an unusable event\n * is skipped and a failed request is logged and swallowed, like every other sender here. The\n * Data Manager API has no partial-failure mode — a rejected request costs the whole batch, so\n * the builder is strict about only emitting events the API can accept. A batch beyond the\n * 2000-event request cap goes out as sequential requests; the first response is returned.\n */\nexport async function sendEvents(\n auth: GoogleAdsAuth,\n config: GoogleAdsConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: GoogleAdsConversionsOptions = {}\n): Promise<DataManagerResponse | undefined> {\n const dmEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getDataManagerEvent(event, config, data))\n .filter((dmEvent): dmEvent is DataManagerEvent => dmEvent !== undefined);\n if (dmEvents.length === 0) return undefined;\n\n const operatingAccount = {\n accountType: 'GOOGLE_ADS',\n accountId: auth.operatingAccountId.replace(/-/g, ''),\n };\n // One destination per conversion action present in the batch; each event points at its own\n // through destinationReferences, so mixed-action batches go out as one request.\n const loginAccount = auth.loginAccountId\n ? { accountType: 'GOOGLE_ADS', accountId: auth.loginAccountId.replace(/-/g, '') }\n : undefined;\n const actions = Array.from(new Set(dmEvents.flatMap((dmEvent) => dmEvent.destinationReferences)));\n const destinations = actions.map((action) => {\n const destination: Record<string, unknown> = {\n reference: action,\n operatingAccount,\n productDestinationId: action,\n };\n if (loginAccount) destination.loginAccount = loginAccount;\n return destination;\n });\n\n let firstResponse: DataManagerResponse | undefined;\n for (let offset = 0; offset < dmEvents.length; offset += MAX_EVENTS_PER_REQUEST) {\n const chunk = dmEvents.slice(offset, offset + MAX_EVENTS_PER_REQUEST);\n const body: Record<string, unknown> = {\n destinations,\n events: chunk,\n validateOnly: options.validateOnly ?? false,\n };\n if (chunk.some((dmEvent) => dmEvent.userData)) {\n body.encoding = 'HEX';\n }\n if (options.consent) {\n body.consent = options.consent;\n }\n\n try {\n const response = await fetch(ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${auth.accessToken}`,\n },\n body: JSON.stringify(body),\n });\n if (response.ok) {\n firstResponse ??= (await response.json()) as DataManagerResponse;\n continue;\n }\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Google Ads conversions, status: ${status}, body: ${message}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Google Ads conversions, network error: ${message}`);\n }\n }\n return firstResponse;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,MAAM,WAAW;;;;;;AAsCjB,SAAgB,eAAe,OAAuB;CACpD,MAAM,QAAQ,MAAM,KAAK,CAAC,CAAC,YAAY;CACvC,MAAM,KAAK,MAAM,YAAY,GAAG;CAChC,IAAI,OAAO,IAAI,OAAO;CACtB,IAAI,WAAW,MAAM,MAAM,GAAG,EAAE;CAChC,MAAM,SAAS,MAAM,MAAM,KAAK,CAAC;CACjC,IAAI,WAAW,eAAe,WAAW,kBACvC,WAAW,SAAS,QAAQ,OAAO,EAAE,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,MAAM;CAE7D,OAAO,GAAG,SAAS,GAAG;AACxB;AAEA,MAAM,UAAU,UAAkB,WAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;AAEjF,SAAS,kBAAkB,OAAwB;CACjD,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,MAAM,WAAW,MAAM,KAAK,CAAC,CAAC,YAAY;CAC1C,OAAO,aAAa,KAAK,QAAQ,IAAI,WAAW;AAClD;;AAGA,MAAM,uBAAuB;;AAG7B,SAAS,mBAAmB,MAAkD;CAC5E,MAAM,cAAwC,CAAC;CAC/C,MAAM,SAAS,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK,QAAQ,KAAK,QAAQ,CAAC,KAAK,KAAK,IAAI,CAAC;CACrF,KAAK,MAAM,SAAS,QAClB,YAAY,KAAK,EAAE,cAAc,OAAO,eAAe,KAAK,CAAC,EAAE,CAAC;CAElE,MAAM,SAAS,MAAM,QAAQ,KAAK,YAAY,IAC1C,KAAK,eACL,KAAK,eACH,CAAC,KAAK,YAAY,IAClB,CAAC;CACP,KAAK,MAAM,SAAS,QAElB,YAAY,KAAK,EAAE,aAAa,OAAO,MAAM,KAAK,CAAC,EAAE,CAAC;CAExD,OAAO,YAAY,MAAM,GAAG,oBAAoB;AAClD;;;;;;;;AAsBA,SAAgB,oBAEd,OACA,QACA,OAAyB,CAAC,GACI;CAC9B,MAAM,SAAS,OAAO,MAAM;CAC5B,IAAI,CAAC,QAAQ,OAAO,KAAA;CAEpB,MAAM,SAAS,oBAAoB,MAAM,QAAQ;CACjD,MAAM,UAA4B;EAChC,uBAAuB,CAAC,OAAO,MAAM,CAAC;EAOtC,eACE,OAAO,MAAM,YAAY,mBAAmB,WACxC,MAAM,WAAW,iBACjB,MAAM;EACZ,gBAAgB,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,YAAY;EACvD,aAAa,WAAW,QAAQ,QAAQ,WAAW,QAAQ,QAAQ;EAInE,iBAAiB,OAAO,MAAM,YAAY,UAAU,WAAW,MAAM,WAAW,QAAQ;EACxF,UAAU,kBAAkB,MAAM,YAAY,QAAQ;CACxD;CAEA,MAAM,EAAE,OAAO,QAAQ,WAAW,MAAM;CACxC,IAAI,OAAO,QAAQ,gBAAgB,EAAE,MAAM;MACtC,IAAI,QAAQ,QAAQ,gBAAgB,EAAE,OAAO;MAC7C,IAAI,QAAQ,QAAQ,gBAAgB,EAAE,OAAO;CAElD,MAAM,kBAAkB,mBAAmB,IAAI;CAC/C,IAAI,gBAAgB,SAAS,GAAG,QAAQ,WAAW,EAAE,gBAAgB;CAGrE,IAAI,CAAC,QAAQ,iBAAiB,CAAC,QAAQ,UAAU,OAAO,KAAA;CAExD,OAAO;AACT;;AAOA,MAAM,yBAAyB;;;;;;;;AAS/B,eAAsB,WACpB,MACA,QAEA,QACA,OAAyB,CAAC,GAC1B,UAAuC,CAAC,GACE;CAC1C,MAAM,WAAW,OACd,QAAQ,UAAU,CAAC,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CACvD,KAAK,UAAU,oBAAoB,OAAO,QAAQ,IAAI,CAAC,CAAC,CACxD,QAAQ,YAAyC,YAAY,KAAA,CAAS;CACzE,IAAI,SAAS,WAAW,GAAG,OAAO,KAAA;CAElC,MAAM,mBAAmB;EACvB,aAAa;EACb,WAAW,KAAK,mBAAmB,QAAQ,MAAM,EAAE;CACrD;CAGA,MAAM,eAAe,KAAK,iBACtB;EAAE,aAAa;EAAc,WAAW,KAAK,eAAe,QAAQ,MAAM,EAAE;CAAE,IAC9E,KAAA;CAEJ,MAAM,eADU,MAAM,KAAK,IAAI,IAAI,SAAS,SAAS,YAAY,QAAQ,qBAAqB,CAAC,CACpE,CAAC,CAAC,KAAK,WAAW;EAC3C,MAAM,cAAuC;GAC3C,WAAW;GACX;GACA,sBAAsB;EACxB;EACA,IAAI,cAAc,YAAY,eAAe;EAC7C,OAAO;CACT,CAAC;CAED,IAAI;CACJ,KAAK,IAAI,SAAS,GAAG,SAAS,SAAS,QAAQ,UAAU,wBAAwB;EAC/E,MAAM,QAAQ,SAAS,MAAM,QAAQ,SAAS,sBAAsB;EACpE,MAAM,OAAgC;GACpC;GACA,QAAQ;GACR,cAAc,QAAQ,gBAAgB;EACxC;EACA,IAAI,MAAM,MAAM,YAAY,QAAQ,QAAQ,GAC1C,KAAK,WAAW;EAElB,IAAI,QAAQ,SACV,KAAK,UAAU,QAAQ;EAGzB,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,UAAU;IACrC,QAAQ;IACR,SAAS;KACP,gBAAgB;KAChB,QAAQ;KACR,eAAe,UAAU,KAAK;IAChC;IACA,MAAM,KAAK,UAAU,IAAI;GAC3B,CAAC;GACD,IAAI,SAAS,IAAI;IACf,kBAAmB,MAAM,SAAS,KAAK;IACvC;GACF;GACA,MAAM,EAAE,WAAW;GACnB,MAAM,UAAU,MAAM,SAAS,KAAK;GACpC,QAAQ,MAAM,kDAAkD,OAAO,UAAU,SAAS;EAC5F,SAAS,OAAO;GACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GACrE,QAAQ,MAAM,yDAAyD,SAAS;EAClF;CACF;CACA,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"google-data-manager.mjs","names":[],"sources":["../../src/server/google-data-manager.ts"],"sourcesContent":["/**\n * Google Ads conversion sender over the Data Manager API (`events:ingest`) — Google's\n * counterpart to Meta's Conversions API, and the mandated successor to the Google Ads API's\n * `UploadClickConversions`, which stopped accepting new adopters on 2026-06-15. Same shape as\n * the other senders here: a pure per-event builder, a never-throws `sendEvents`, credentials\n * in headers and never in a URL or a log line.\n *\n * Google's recommended deployment is *hybrid* (\"boost your tag with additional data sources\"):\n * this upload attaches to the SAME conversion action the gtag tag reports to, matched by\n * `transactionId` — a matched pair collapses into one conversion with the server data winning,\n * an unmatched upload is a recovered conversion. Both channels here are fed from the same\n * `track()` call, so the ids agree by construction. `transactionId` is therefore required in\n * that mode; this sender always sends one (the event id when there is no natural order id).\n *\n * Auth is a plain OAuth2 access token with the `datamanager` scope — no Google Ads developer\n * token, no API Center approval. The caller exchanges its stored refresh token for the access\n * token (one POST to oauth2.googleapis.com) and refreshes it however it likes; this module\n * stays out of the OAuth business, exactly as it stays out of Meta's.\n *\n * https://developers.google.com/data-manager/api/reference/rest/v1/events/ingest\n * https://developers.google.com/data-manager/api/devguides/events/google-ads/offline/upgrade/field-mappings\n * https://support.google.com/google-ads/answer/16542291 (hybrid: \"boost your tag with\n * additional data sources\" — the transaction-id matching contract this sender relies on)\n */\nimport { createHash } from 'node:crypto';\nimport { fetch } from '@shware/utils';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { resolveActionSource } from './action-source';\n\nconst ENDPOINT = 'https://datamanager.googleapis.com/v1/events:ingest';\n\n/**\n * Event name → conversion action id, mirroring `LinkedinConversionConfig`: only configured\n * events upload, everything else is skipped silently. In the hybrid setup this is the id of\n * the SAME conversion action the gtag tag reports to.\n */\nexport type GoogleAdsConversionConfig = Record<Lowercase<string>, number | string>;\n\nexport interface GoogleAdsAuth {\n /** OAuth2 access token with the `datamanager` scope. Short-lived; the caller refreshes it. */\n accessToken: string;\n /** The Google Ads account the conversion actions live in. Dashes are tolerated: '123-456-7890'. */\n operatingAccountId: string;\n /** The manager account the OAuth user accesses through, when access is delegated. */\n loginAccountId?: string;\n}\n\n/**\n * EEA/UK/CH consent, applied request-wide. Google drops EEA conversions without granted\n * `adUserData` from bidding, so a host with consent signals should pass them.\n */\nexport interface GoogleAdsConsent {\n adUserData?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n adPersonalization?: 'CONSENT_GRANTED' | 'CONSENT_DENIED' | 'CONSENT_STATUS_UNSPECIFIED';\n}\n\nexport interface GoogleAdsConversionsOptions {\n /** Validates the payload server-side without recording conversions. */\n validateOnly?: boolean;\n consent?: GoogleAdsConsent;\n}\n\n/**\n * Google's email normalization is Meta's minus one wrinkle: for @gmail.com/@googlemail.com the\n * username's dots and everything from '+' on are removed; for every other domain they are kept.\n * https://developers.google.com/data-manager/api/devguides/format-data\n */\nexport function normalizeEmail(input: string): string {\n const email = input.trim().toLowerCase();\n const at = email.lastIndexOf('@');\n if (at === -1) return email;\n let username = email.slice(0, at);\n const domain = email.slice(at + 1);\n if (domain === 'gmail.com' || domain === 'googlemail.com') {\n username = username.replace(/\\./g, '').split('+', 1)[0] ?? '';\n }\n return `${username}@${domain}`;\n}\n\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\nfunction normalizeCurrency(input: unknown): string {\n if (typeof input !== 'string') return 'USD';\n const currency = input.trim().toUpperCase();\n return /^[A-Z]{3}$/.test(currency) ? currency : 'USD';\n}\n\n/** At most 10 userIdentifiers per event, per the API's hard limit. */\nconst MAX_USER_IDENTIFIERS = 10;\n\n/** Hashes are hex strings, declared by `encoding: 'HEX'` on the request. */\nfunction getUserIdentifiers(data: UserProvidedData): Record<string, string>[] {\n const identifiers: Record<string, string>[] = [];\n const emails = Array.isArray(data.email) ? data.email : data.email ? [data.email] : [];\n for (const email of emails) {\n identifiers.push({ emailAddress: sha256(normalizeEmail(email)) });\n }\n const phones = Array.isArray(data.phone_number)\n ? data.phone_number\n : data.phone_number\n ? [data.phone_number]\n : [];\n for (const phone of phones) {\n // Phones are already E.164 by the schema ('+' then digits); hash after a defensive trim.\n identifiers.push({ phoneNumber: sha256(phone.trim()) });\n }\n return identifiers.slice(0, MAX_USER_IDENTIFIERS);\n}\n\n/** One entry of the `events` array. Field names are the REST (camelCase) spellings. */\nexport interface DataManagerEvent {\n destinationReferences: string[];\n transactionId: string;\n /** RFC 3339 — `created_at` as stored, unlike the legacy API's bespoke format. */\n eventTimestamp: string;\n eventSource: 'WEB' | 'APP' | 'OTHER';\n conversionValue: number;\n currency: string;\n adIdentifiers?: { gclid?: string; gbraid?: string; wbraid?: string };\n userData?: { userIdentifiers: Record<string, string>[] };\n}\n\n/**\n * Builds one Event, or undefined when no conversion action is configured for the event's name.\n * At most one click identifier is sent, gclid first: it is the deterministic per-click id,\n * where gbraid/wbraid are the aggregate iOS ones, and Google documents preferring it. Unlike\n * the legacy upload, an event with no click id at all is still worth sending when it carries\n * user identifiers — that is the enhanced-conversions match path.\n */\nexport function getDataManagerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n config: GoogleAdsConversionConfig,\n data: UserProvidedData = {}\n): DataManagerEvent | undefined {\n const action = config[event.name as Lowercase<string>];\n if (!action) return undefined;\n\n const source = resolveActionSource(event.platform);\n const dmEvent: DataManagerEvent = {\n destinationReferences: [String(action)],\n // The hybrid-mode match key against the gtag tag's transaction_id: same track() call feeds\n // both channels, so they agree by construction for purchase-shaped events. The event id\n // covers conversions with no natural order id — but the gtag tracker only sends a\n // transaction_id when the properties carry one, so on a HYBRID action a non-purchase event\n // reported by both channels never matches and counts twice. Attach such events to a\n // standalone (API-only) conversion action instead.\n transactionId:\n typeof event.properties?.transaction_id === 'string'\n ? event.properties.transaction_id\n : event.id,\n eventTimestamp: new Date(event.created_at).toISOString(),\n eventSource: source === 'app' ? 'APP' : source === 'web' ? 'WEB' : 'OTHER',\n // Both mandatory, like the LinkedIn sender's defaults: 0 USD is Google's own convention\n // for value-less goals. The API has no partial-failure mode, so a malformed currency would\n // cost the whole batch — anything that isn't a 3-letter code falls back instead.\n conversionValue: typeof event.properties?.value === 'number' ? event.properties.value : 0,\n currency: normalizeCurrency(event.properties?.currency),\n };\n\n const { gclid, gbraid, wbraid } = event.tags;\n if (gclid) dmEvent.adIdentifiers = { gclid };\n else if (gbraid) dmEvent.adIdentifiers = { gbraid };\n else if (wbraid) dmEvent.adIdentifiers = { wbraid };\n\n const userIdentifiers = getUserIdentifiers(data);\n if (userIdentifiers.length > 0) dmEvent.userData = { userIdentifiers };\n\n // With neither a click id nor an identifier Google has nothing to match on; skip.\n if (!dmEvent.adIdentifiers && !dmEvent.userData) return undefined;\n\n return dmEvent;\n}\n\nexport interface DataManagerResponse {\n requestId?: string;\n}\n\n/** The API takes at most 2000 events per request. */\nconst MAX_EVENTS_PER_REQUEST = 2000;\n\n/**\n * Uploads the configured conversions from a batch of events. Never throws: an unusable event\n * is skipped and a failed request is logged and swallowed, like every other sender here. The\n * Data Manager API has no partial-failure mode — a rejected request costs the whole batch, so\n * the builder is strict about only emitting events the API can accept. A batch beyond the\n * 2000-event request cap goes out as sequential requests; the first response is returned.\n */\nexport async function sendEvents(\n auth: GoogleAdsAuth,\n config: GoogleAdsConversionConfig,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: GoogleAdsConversionsOptions = {}\n): Promise<DataManagerResponse | undefined> {\n const dmEvents = events\n .filter((event) => !IGNORED_EVENTS.includes(event.name))\n .map((event) => getDataManagerEvent(event, config, data))\n .filter((dmEvent): dmEvent is DataManagerEvent => dmEvent !== undefined);\n if (dmEvents.length === 0) return undefined;\n\n const operatingAccount = {\n accountType: 'GOOGLE_ADS',\n accountId: auth.operatingAccountId.replace(/-/g, ''),\n };\n // One destination per conversion action present in the batch; each event points at its own\n // through destinationReferences, so mixed-action batches go out as one request.\n const loginAccount = auth.loginAccountId\n ? { accountType: 'GOOGLE_ADS', accountId: auth.loginAccountId.replace(/-/g, '') }\n : undefined;\n const actions = Array.from(new Set(dmEvents.flatMap((dmEvent) => dmEvent.destinationReferences)));\n const destinations = actions.map((action) => {\n const destination: Record<string, unknown> = {\n reference: action,\n operatingAccount,\n productDestinationId: action,\n };\n if (loginAccount) destination.loginAccount = loginAccount;\n return destination;\n });\n\n let firstResponse: DataManagerResponse | undefined;\n for (let offset = 0; offset < dmEvents.length; offset += MAX_EVENTS_PER_REQUEST) {\n const chunk = dmEvents.slice(offset, offset + MAX_EVENTS_PER_REQUEST);\n const body: Record<string, unknown> = {\n destinations,\n events: chunk,\n validateOnly: options.validateOnly ?? false,\n };\n if (chunk.some((dmEvent) => dmEvent.userData)) {\n body.encoding = 'HEX';\n }\n if (options.consent) {\n body.consent = options.consent;\n }\n\n try {\n const response = await fetch(ENDPOINT, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${auth.accessToken}`,\n },\n body: JSON.stringify(body),\n });\n if (response.ok) {\n firstResponse ??= (await response.json()) as DataManagerResponse;\n continue;\n }\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Google Ads conversions, status: ${status}, body: ${message}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Google Ads conversions, network error: ${message}`);\n }\n }\n return firstResponse;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,WAAW;;;;;;AAsCjB,SAAgB,eAAe,OAAuB;CACpD,MAAM,QAAQ,MAAM,KAAK,CAAC,CAAC,YAAY;CACvC,MAAM,KAAK,MAAM,YAAY,GAAG;CAChC,IAAI,OAAO,IAAI,OAAO;CACtB,IAAI,WAAW,MAAM,MAAM,GAAG,EAAE;CAChC,MAAM,SAAS,MAAM,MAAM,KAAK,CAAC;CACjC,IAAI,WAAW,eAAe,WAAW,kBACvC,WAAW,SAAS,QAAQ,OAAO,EAAE,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,MAAM;CAE7D,OAAO,GAAG,SAAS,GAAG;AACxB;AAEA,MAAM,UAAU,UAAkB,WAAW,QAAQ,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,OAAO,KAAK;AAEjF,SAAS,kBAAkB,OAAwB;CACjD,IAAI,OAAO,UAAU,UAAU,OAAO;CACtC,MAAM,WAAW,MAAM,KAAK,CAAC,CAAC,YAAY;CAC1C,OAAO,aAAa,KAAK,QAAQ,IAAI,WAAW;AAClD;;AAGA,MAAM,uBAAuB;;AAG7B,SAAS,mBAAmB,MAAkD;CAC5E,MAAM,cAAwC,CAAC;CAC/C,MAAM,SAAS,MAAM,QAAQ,KAAK,KAAK,IAAI,KAAK,QAAQ,KAAK,QAAQ,CAAC,KAAK,KAAK,IAAI,CAAC;CACrF,KAAK,MAAM,SAAS,QAClB,YAAY,KAAK,EAAE,cAAc,OAAO,eAAe,KAAK,CAAC,EAAE,CAAC;CAElE,MAAM,SAAS,MAAM,QAAQ,KAAK,YAAY,IAC1C,KAAK,eACL,KAAK,eACH,CAAC,KAAK,YAAY,IAClB,CAAC;CACP,KAAK,MAAM,SAAS,QAElB,YAAY,KAAK,EAAE,aAAa,OAAO,MAAM,KAAK,CAAC,EAAE,CAAC;CAExD,OAAO,YAAY,MAAM,GAAG,oBAAoB;AAClD;;;;;;;;AAsBA,SAAgB,oBAEd,OACA,QACA,OAAyB,CAAC,GACI;CAC9B,MAAM,SAAS,OAAO,MAAM;CAC5B,IAAI,CAAC,QAAQ,OAAO,KAAA;CAEpB,MAAM,SAAS,oBAAoB,MAAM,QAAQ;CACjD,MAAM,UAA4B;EAChC,uBAAuB,CAAC,OAAO,MAAM,CAAC;EAOtC,eACE,OAAO,MAAM,YAAY,mBAAmB,WACxC,MAAM,WAAW,iBACjB,MAAM;EACZ,gBAAgB,IAAI,KAAK,MAAM,UAAU,CAAC,CAAC,YAAY;EACvD,aAAa,WAAW,QAAQ,QAAQ,WAAW,QAAQ,QAAQ;EAInE,iBAAiB,OAAO,MAAM,YAAY,UAAU,WAAW,MAAM,WAAW,QAAQ;EACxF,UAAU,kBAAkB,MAAM,YAAY,QAAQ;CACxD;CAEA,MAAM,EAAE,OAAO,QAAQ,WAAW,MAAM;CACxC,IAAI,OAAO,QAAQ,gBAAgB,EAAE,MAAM;MACtC,IAAI,QAAQ,QAAQ,gBAAgB,EAAE,OAAO;MAC7C,IAAI,QAAQ,QAAQ,gBAAgB,EAAE,OAAO;CAElD,MAAM,kBAAkB,mBAAmB,IAAI;CAC/C,IAAI,gBAAgB,SAAS,GAAG,QAAQ,WAAW,EAAE,gBAAgB;CAGrE,IAAI,CAAC,QAAQ,iBAAiB,CAAC,QAAQ,UAAU,OAAO,KAAA;CAExD,OAAO;AACT;;AAOA,MAAM,yBAAyB;;;;;;;;AAS/B,eAAsB,WACpB,MACA,QAEA,QACA,OAAyB,CAAC,GAC1B,UAAuC,CAAC,GACE;CAC1C,MAAM,WAAW,OACd,QAAQ,UAAU,CAAC,eAAe,SAAS,MAAM,IAAI,CAAC,CAAC,CACvD,KAAK,UAAU,oBAAoB,OAAO,QAAQ,IAAI,CAAC,CAAC,CACxD,QAAQ,YAAyC,YAAY,KAAA,CAAS;CACzE,IAAI,SAAS,WAAW,GAAG,OAAO,KAAA;CAElC,MAAM,mBAAmB;EACvB,aAAa;EACb,WAAW,KAAK,mBAAmB,QAAQ,MAAM,EAAE;CACrD;CAGA,MAAM,eAAe,KAAK,iBACtB;EAAE,aAAa;EAAc,WAAW,KAAK,eAAe,QAAQ,MAAM,EAAE;CAAE,IAC9E,KAAA;CAEJ,MAAM,eADU,MAAM,KAAK,IAAI,IAAI,SAAS,SAAS,YAAY,QAAQ,qBAAqB,CAAC,CACpE,CAAC,CAAC,KAAK,WAAW;EAC3C,MAAM,cAAuC;GAC3C,WAAW;GACX;GACA,sBAAsB;EACxB;EACA,IAAI,cAAc,YAAY,eAAe;EAC7C,OAAO;CACT,CAAC;CAED,IAAI;CACJ,KAAK,IAAI,SAAS,GAAG,SAAS,SAAS,QAAQ,UAAU,wBAAwB;EAC/E,MAAM,QAAQ,SAAS,MAAM,QAAQ,SAAS,sBAAsB;EACpE,MAAM,OAAgC;GACpC;GACA,QAAQ;GACR,cAAc,QAAQ,gBAAgB;EACxC;EACA,IAAI,MAAM,MAAM,YAAY,QAAQ,QAAQ,GAC1C,KAAK,WAAW;EAElB,IAAI,QAAQ,SACV,KAAK,UAAU,QAAQ;EAGzB,IAAI;GACF,MAAM,WAAW,MAAM,MAAM,UAAU;IACrC,QAAQ;IACR,SAAS;KACP,gBAAgB;KAChB,QAAQ;KACR,eAAe,UAAU,KAAK;IAChC;IACA,MAAM,KAAK,UAAU,IAAI;GAC3B,CAAC;GACD,IAAI,SAAS,IAAI;IACf,kBAAmB,MAAM,SAAS,KAAK;IACvC;GACF;GACA,MAAM,EAAE,WAAW;GACnB,MAAM,UAAU,MAAM,SAAS,KAAK;GACpC,QAAQ,MAAM,kDAAkD,OAAO,UAAU,SAAS;EAC5F,SAAS,OAAO;GACd,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GACrE,QAAQ,MAAM,yDAAyD,SAAS;EAClF;CACF;CACA,OAAO;AACT"}
|
package/dist/server/index.cjs
CHANGED
|
@@ -5,21 +5,29 @@ const require_server_meta_capi = require("./meta-capi.cjs");
|
|
|
5
5
|
const require_server_reddit_conversions_api = require("./reddit-conversions-api.cjs");
|
|
6
6
|
const require_server_linkedin_conversions_api = require("./linkedin-conversions-api.cjs");
|
|
7
7
|
const require_server_openai_conversions_api = require("./openai-conversions-api.cjs");
|
|
8
|
+
const require_server_microsoft_conversions_api = require("./microsoft-conversions-api.cjs");
|
|
8
9
|
exports.FBC_COOKIE = require_click_id_index.FBC_COOKIE;
|
|
9
10
|
exports.GCL_AW_COOKIE = require_click_id_index.GCL_AW_COOKIE;
|
|
10
11
|
exports.GCL_GB_COOKIE = require_click_id_index.GCL_GB_COOKIE;
|
|
11
12
|
exports.RDT_CID_COOKIE = require_click_id_index.RDT_CID_COOKIE;
|
|
13
|
+
exports.UET_MSCLKID_COOKIE = require_click_id_index.UET_MSCLKID_COOKIE;
|
|
12
14
|
exports.formatFbc = require_click_id_index.formatFbc;
|
|
13
15
|
exports.formatGcl = require_click_id_index.formatGcl;
|
|
16
|
+
exports.formatMsclkid = require_click_id_index.formatMsclkid;
|
|
17
|
+
exports.formatUetMsclkid = require_click_id_index.formatUetMsclkid;
|
|
14
18
|
exports.getCapiEvent = require_server_meta_capi.getCapiEvent;
|
|
15
19
|
exports.getDataManagerEvent = require_server_google_data_manager.getDataManagerEvent;
|
|
20
|
+
exports.getMicrosoftEvent = require_server_microsoft_conversions_api.getServerEvent;
|
|
16
21
|
exports.normalizeEmail = require_server_google_data_manager.normalizeEmail;
|
|
22
|
+
exports.normalizeMicrosoftEmail = require_server_microsoft_conversions_api.normalizeEmail;
|
|
17
23
|
exports.parseFbc = require_click_id_index.parseFbc;
|
|
18
24
|
exports.parseGcl = require_click_id_index.parseGcl;
|
|
25
|
+
exports.parseUetMsclkid = require_click_id_index.parseUetMsclkid;
|
|
19
26
|
exports.resolveClickIdCookies = require_click_id_index.resolveClickIdCookies;
|
|
20
27
|
exports.sendGoogleAdsEvents = require_server_google_data_manager.sendEvents;
|
|
21
28
|
exports.sendLinkedinEvents = require_server_linkedin_conversions_api.sendEvents;
|
|
22
29
|
exports.sendMetaEvents = require_server_meta_capi.sendEvents;
|
|
30
|
+
exports.sendMicrosoftEvents = require_server_microsoft_conversions_api.sendEvents;
|
|
23
31
|
exports.sendOpenAIEvents = require_server_openai_conversions_api.sendEvents;
|
|
24
32
|
exports.sendRedditEvents = require_server_reddit_conversions_api.sendEvents;
|
|
25
33
|
exports.toSetCookieHeaders = require_click_id_index.toSetCookieHeaders;
|
package/dist/server/index.d.cts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, ParsedFbc, ParsedGcl, RDT_CID_COOKIE, ResolveClickIdCookiesInput, ResolveClickIdCookiesResult, formatFbc, formatGcl, parseFbc, parseGcl, resolveClickIdCookies, toSetCookieHeaders } from "../click-id/index.cjs";
|
|
1
|
+
import { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, ParsedFbc, ParsedGcl, RDT_CID_COOKIE, ResolveClickIdCookiesInput, ResolveClickIdCookiesResult, UET_MSCLKID_COOKIE, formatFbc, formatGcl, formatMsclkid, formatUetMsclkid, parseFbc, parseGcl, parseUetMsclkid, resolveClickIdCookies, toSetCookieHeaders } from "../click-id/index.cjs";
|
|
2
2
|
import { EventActionSource } from "./action-source.cjs";
|
|
3
3
|
import { DataManagerEvent, DataManagerResponse, GoogleAdsAuth, GoogleAdsConsent, GoogleAdsConversionConfig, GoogleAdsConversionsOptions, getDataManagerEvent, normalizeEmail, sendEvents } from "./google-data-manager.cjs";
|
|
4
4
|
import { CapiEvent, MetaConversionsOptions, MetaConversionsResponse, getCapiEvent, sendEvents as sendEvents$2 } from "./meta-capi.cjs";
|
|
5
|
-
import { sendEvents as sendEvents$
|
|
5
|
+
import { sendEvents as sendEvents$5 } from "./reddit-conversions-api.cjs";
|
|
6
6
|
import { LinkedinConversionConfig, sendEvents as sendEvents$1 } from "./linkedin-conversions-api.cjs";
|
|
7
|
-
import { sendEvents as sendEvents$
|
|
8
|
-
|
|
7
|
+
import { sendEvents as sendEvents$4 } from "./openai-conversions-api.cjs";
|
|
8
|
+
import { MicrosoftConversionsOptions, MicrosoftConversionsResponse, MicrosoftCustomData, MicrosoftEvent, MicrosoftUserData, MicrosoftValidationDetail, getServerEvent, normalizeEmail as normalizeEmail$1, sendEvents as sendEvents$3 } from "./microsoft-conversions-api.cjs";
|
|
9
|
+
export { type CapiEvent, type DataManagerEvent, type DataManagerResponse, type EventActionSource, FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, type GoogleAdsAuth, type GoogleAdsConsent, type GoogleAdsConversionConfig, type GoogleAdsConversionsOptions, type LinkedinConversionConfig, type MetaConversionsOptions, type MetaConversionsResponse, type MicrosoftConversionsOptions, type MicrosoftConversionsResponse, type MicrosoftCustomData, type MicrosoftEvent, type MicrosoftUserData, type MicrosoftValidationDetail, type ParsedFbc, type ParsedGcl, RDT_CID_COOKIE, type ResolveClickIdCookiesInput, type ResolveClickIdCookiesResult, UET_MSCLKID_COOKIE, formatFbc, formatGcl, formatMsclkid, formatUetMsclkid, getCapiEvent, getDataManagerEvent, getServerEvent as getMicrosoftEvent, normalizeEmail, normalizeEmail$1 as normalizeMicrosoftEmail, parseFbc, parseGcl, parseUetMsclkid, resolveClickIdCookies, sendEvents as sendGoogleAdsEvents, sendEvents$1 as sendLinkedinEvents, sendEvents$2 as sendMetaEvents, sendEvents$3 as sendMicrosoftEvents, sendEvents$4 as sendOpenAIEvents, sendEvents$5 as sendRedditEvents, toSetCookieHeaders };
|
package/dist/server/index.d.mts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, ParsedFbc, ParsedGcl, RDT_CID_COOKIE, ResolveClickIdCookiesInput, ResolveClickIdCookiesResult, formatFbc, formatGcl, parseFbc, parseGcl, resolveClickIdCookies, toSetCookieHeaders } from "../click-id/index.mjs";
|
|
1
|
+
import { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, ParsedFbc, ParsedGcl, RDT_CID_COOKIE, ResolveClickIdCookiesInput, ResolveClickIdCookiesResult, UET_MSCLKID_COOKIE, formatFbc, formatGcl, formatMsclkid, formatUetMsclkid, parseFbc, parseGcl, parseUetMsclkid, resolveClickIdCookies, toSetCookieHeaders } from "../click-id/index.mjs";
|
|
2
2
|
import { EventActionSource } from "./action-source.mjs";
|
|
3
3
|
import { DataManagerEvent, DataManagerResponse, GoogleAdsAuth, GoogleAdsConsent, GoogleAdsConversionConfig, GoogleAdsConversionsOptions, getDataManagerEvent, normalizeEmail, sendEvents } from "./google-data-manager.mjs";
|
|
4
4
|
import { CapiEvent, MetaConversionsOptions, MetaConversionsResponse, getCapiEvent, sendEvents as sendEvents$2 } from "./meta-capi.mjs";
|
|
5
|
-
import { sendEvents as sendEvents$
|
|
5
|
+
import { sendEvents as sendEvents$5 } from "./reddit-conversions-api.mjs";
|
|
6
6
|
import { LinkedinConversionConfig, sendEvents as sendEvents$1 } from "./linkedin-conversions-api.mjs";
|
|
7
|
-
import { sendEvents as sendEvents$
|
|
8
|
-
|
|
7
|
+
import { sendEvents as sendEvents$4 } from "./openai-conversions-api.mjs";
|
|
8
|
+
import { MicrosoftConversionsOptions, MicrosoftConversionsResponse, MicrosoftCustomData, MicrosoftEvent, MicrosoftUserData, MicrosoftValidationDetail, getServerEvent, normalizeEmail as normalizeEmail$1, sendEvents as sendEvents$3 } from "./microsoft-conversions-api.mjs";
|
|
9
|
+
export { type CapiEvent, type DataManagerEvent, type DataManagerResponse, type EventActionSource, FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, type GoogleAdsAuth, type GoogleAdsConsent, type GoogleAdsConversionConfig, type GoogleAdsConversionsOptions, type LinkedinConversionConfig, type MetaConversionsOptions, type MetaConversionsResponse, type MicrosoftConversionsOptions, type MicrosoftConversionsResponse, type MicrosoftCustomData, type MicrosoftEvent, type MicrosoftUserData, type MicrosoftValidationDetail, type ParsedFbc, type ParsedGcl, RDT_CID_COOKIE, type ResolveClickIdCookiesInput, type ResolveClickIdCookiesResult, UET_MSCLKID_COOKIE, formatFbc, formatGcl, formatMsclkid, formatUetMsclkid, getCapiEvent, getDataManagerEvent, getServerEvent as getMicrosoftEvent, normalizeEmail, normalizeEmail$1 as normalizeMicrosoftEmail, parseFbc, parseGcl, parseUetMsclkid, resolveClickIdCookies, sendEvents as sendGoogleAdsEvents, sendEvents$1 as sendLinkedinEvents, sendEvents$2 as sendMetaEvents, sendEvents$3 as sendMicrosoftEvents, sendEvents$4 as sendOpenAIEvents, sendEvents$5 as sendRedditEvents, toSetCookieHeaders };
|
package/dist/server/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, RDT_CID_COOKIE, formatFbc, formatGcl, parseFbc, parseGcl, resolveClickIdCookies, toSetCookieHeaders } from "../click-id/index.mjs";
|
|
1
|
+
import { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, RDT_CID_COOKIE, UET_MSCLKID_COOKIE, formatFbc, formatGcl, formatMsclkid, formatUetMsclkid, parseFbc, parseGcl, parseUetMsclkid, resolveClickIdCookies, toSetCookieHeaders } from "../click-id/index.mjs";
|
|
2
2
|
import { getDataManagerEvent, normalizeEmail, sendEvents } from "./google-data-manager.mjs";
|
|
3
3
|
import { getCapiEvent, sendEvents as sendEvents$2 } from "./meta-capi.mjs";
|
|
4
|
-
import { sendEvents as sendEvents$
|
|
4
|
+
import { sendEvents as sendEvents$5 } from "./reddit-conversions-api.mjs";
|
|
5
5
|
import { sendEvents as sendEvents$1 } from "./linkedin-conversions-api.mjs";
|
|
6
|
-
import { sendEvents as sendEvents$
|
|
7
|
-
|
|
6
|
+
import { sendEvents as sendEvents$4 } from "./openai-conversions-api.mjs";
|
|
7
|
+
import { getServerEvent, normalizeEmail as normalizeEmail$1, sendEvents as sendEvents$3 } from "./microsoft-conversions-api.mjs";
|
|
8
|
+
export { FBC_COOKIE, GCL_AW_COOKIE, GCL_GB_COOKIE, RDT_CID_COOKIE, UET_MSCLKID_COOKIE, formatFbc, formatGcl, formatMsclkid, formatUetMsclkid, getCapiEvent, getDataManagerEvent, getServerEvent as getMicrosoftEvent, normalizeEmail, normalizeEmail$1 as normalizeMicrosoftEmail, parseFbc, parseGcl, parseUetMsclkid, resolveClickIdCookies, sendEvents as sendGoogleAdsEvents, sendEvents$1 as sendLinkedinEvents, sendEvents$2 as sendMetaEvents, sendEvents$3 as sendMicrosoftEvents, sendEvents$4 as sendOpenAIEvents, sendEvents$5 as sendRedditEvents, toSetCookieHeaders };
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_third_parties_ignored_events = require("../third-parties/ignored-events.cjs");
|
|
3
|
+
const require_click_id_index = require("../click-id/index.cjs");
|
|
4
|
+
const require_server_page_location = require("./page-location.cjs");
|
|
5
|
+
const require_track_uetq = require("../track/uetq.cjs");
|
|
6
|
+
let _shware_utils = require("@shware/utils");
|
|
7
|
+
let node_crypto = require("node:crypto");
|
|
8
|
+
//#region src/server/microsoft-conversions-api.ts
|
|
9
|
+
/**
|
|
10
|
+
* Microsoft Advertising Conversions API (CAPI) sender — the server-side counterpart of the UET
|
|
11
|
+
* tag, in the same shape as the other senders here: a pure per-event builder, a never-throws
|
|
12
|
+
* `sendEvents`, the token in a header and never in a URL or a log line.
|
|
13
|
+
*
|
|
14
|
+
* Microsoft's recommended deployment is hybrid: keep the UET JavaScript on the page and send the
|
|
15
|
+
* same conversions here too, deduplicated by (`tagId`, `eventName`, `eventId`). Both channels in
|
|
16
|
+
* this SDK are fed from the same `track()` call — the browser sends the internal name as the UET
|
|
17
|
+
* action with the event id as `event_id`, and this builder sends the same name as `eventName`
|
|
18
|
+
* with the same id — so the pair agrees by construction.
|
|
19
|
+
*
|
|
20
|
+
* The token comes from the UET tag's "Use Conversions API" setup step, or from the Campaign
|
|
21
|
+
* Management API's `UetTagAuthKey/Query`. Access is provisioned per customer account: an account
|
|
22
|
+
* whose tag setup has no "Use Conversions API" option has to ask Microsoft Advertising support.
|
|
23
|
+
*
|
|
24
|
+
* https://learn.microsoft.com/en-us/advertising/guides/uet-conversion-api-integration
|
|
25
|
+
*/
|
|
26
|
+
const ENDPOINT = "https://capi.uet.microsoft.com/v1";
|
|
27
|
+
/** The API's hard limit per request. */
|
|
28
|
+
const MAX_EVENTS_PER_REQUEST = 1e3;
|
|
29
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
30
|
+
const sha256 = (value) => (0, node_crypto.createHash)("sha256").update(value).digest("hex");
|
|
31
|
+
/**
|
|
32
|
+
* Microsoft's email normalization: trim, drop the dots and any `+alias` from the user part —
|
|
33
|
+
* for EVERY domain, unlike Google's gmail-only rule — lowercase, SHA-256. A value that is
|
|
34
|
+
* already a SHA-256 hex digest passes through.
|
|
35
|
+
* https://learn.microsoft.com/en-us/advertising/guides/uet-conversion-api-integration#hashed-identifiers
|
|
36
|
+
*/
|
|
37
|
+
function normalizeEmail(input) {
|
|
38
|
+
const email = input.trim().toLowerCase();
|
|
39
|
+
const at = email.lastIndexOf("@");
|
|
40
|
+
if (at === -1) return email;
|
|
41
|
+
return `${email.slice(0, at).replace(/\./g, "").split("+", 1)[0] ?? ""}@${email.slice(at + 1)}`;
|
|
42
|
+
}
|
|
43
|
+
function hashEmail(input) {
|
|
44
|
+
const value = input?.trim().toLowerCase();
|
|
45
|
+
if (!value) return void 0;
|
|
46
|
+
return SHA256.test(value) ? value : sha256(normalizeEmail(value));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Phones are E.164 by the schema (`+` then 11–15 digits, the range bat.js validates too); the
|
|
50
|
+
* separators bat.js tolerates are stripped, and anything else is dropped rather than guessed —
|
|
51
|
+
* a number with no country code hashed under an assumed one can never match.
|
|
52
|
+
*/
|
|
53
|
+
function hashPhone(input) {
|
|
54
|
+
const value = input?.trim().toLowerCase();
|
|
55
|
+
if (!value) return void 0;
|
|
56
|
+
if (SHA256.test(value)) return value;
|
|
57
|
+
const phone = value.replace(/[-() \t]/g, "");
|
|
58
|
+
return /^\+\d{11,15}$/.test(phone) ? sha256(phone) : void 0;
|
|
59
|
+
}
|
|
60
|
+
function getUserData(event, data) {
|
|
61
|
+
const { tags, platform, visitor_id } = event;
|
|
62
|
+
const email = Array.isArray(data.email) ? data.email[0] : data.email;
|
|
63
|
+
const phone = Array.isArray(data.phone_number) ? data.phone_number[0] : data.phone_number;
|
|
64
|
+
return {
|
|
65
|
+
msclkid: tags.msclkid ? require_click_id_index.formatMsclkid(tags.msclkid) : void 0,
|
|
66
|
+
em: hashEmail(email),
|
|
67
|
+
ph: hashPhone(phone),
|
|
68
|
+
anonymousId: visitor_id || void 0,
|
|
69
|
+
externalId: data.user_id ? sha256(data.user_id) : void 0,
|
|
70
|
+
clientUserAgent: data.user_agent,
|
|
71
|
+
clientIpAddress: typeof tags.ip_address === "string" && tags.ip_address ? tags.ip_address : data.ip_address,
|
|
72
|
+
idfa: platform === "ios" ? tags.advertising_id : void 0,
|
|
73
|
+
gaid: platform === "android" ? tags.advertising_id : void 0
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function getCustomData(event) {
|
|
77
|
+
const [, params] = require_track_uetq.mapUETEvent(event.name, event.properties);
|
|
78
|
+
const customData = {
|
|
79
|
+
eventCategory: params.event_category,
|
|
80
|
+
eventLabel: params.event_label,
|
|
81
|
+
eventValue: params.event_value,
|
|
82
|
+
searchTerm: params.search_term,
|
|
83
|
+
transactionId: params.transaction_id,
|
|
84
|
+
value: params.revenue_value,
|
|
85
|
+
currency: params.currency,
|
|
86
|
+
items: params.items?.map(({ id, name, price, quantity }) => ({
|
|
87
|
+
id,
|
|
88
|
+
name,
|
|
89
|
+
price,
|
|
90
|
+
quantity
|
|
91
|
+
})),
|
|
92
|
+
itemIds: Array.isArray(params.ecomm_prodid) ? params.ecomm_prodid : params.ecomm_prodid ? [params.ecomm_prodid] : void 0,
|
|
93
|
+
pageType: params.ecomm_pagetype,
|
|
94
|
+
ecommTotalValue: params.ecomm_totalvalue,
|
|
95
|
+
ecommCategory: params.ecomm_category
|
|
96
|
+
};
|
|
97
|
+
return Object.values(customData).some((value) => value !== void 0) ? customData : void 0;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Build one CAPI event. `page_view` becomes a `pageLoad` event (the caller decides whether to
|
|
101
|
+
* send those, see `MicrosoftConversionsOptions.pageLoads`); everything else is a `custom` event
|
|
102
|
+
* named after the track event, which is also the UET action the browser sent.
|
|
103
|
+
*/
|
|
104
|
+
function getServerEvent(event, data = {}, consent) {
|
|
105
|
+
const { id, name, tags, created_at } = event;
|
|
106
|
+
const isPageLoad = name === "page_view";
|
|
107
|
+
return {
|
|
108
|
+
eventType: isPageLoad ? "pageLoad" : "custom",
|
|
109
|
+
eventId: tags.idempotency_key ?? id,
|
|
110
|
+
eventName: isPageLoad ? void 0 : name,
|
|
111
|
+
eventTime: Math.round(new Date(created_at).getTime() / 1e3),
|
|
112
|
+
eventSourceUrl: require_server_page_location.pageLocation(tags),
|
|
113
|
+
referrerUrl: tags.page_referrer,
|
|
114
|
+
pageTitle: tags.page_title,
|
|
115
|
+
adStorageConsent: consent === "granted" ? "G" : consent === "denied" ? "D" : void 0,
|
|
116
|
+
userData: getUserData(event, data),
|
|
117
|
+
customData: isPageLoad ? void 0 : getCustomData(event)
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Strip undefined fields so the wire payload carries only what was set. */
|
|
121
|
+
function compact(value) {
|
|
122
|
+
return JSON.parse(JSON.stringify(value));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Send events to `POST /v1/{tagId}/events`. Batches above the API's 1,000-event limit are split
|
|
126
|
+
* into consecutive requests; one response is returned per request made. Never throws: an HTTP
|
|
127
|
+
* or network failure is logged (without the token) and yields no response for that request.
|
|
128
|
+
*/
|
|
129
|
+
async function sendEvents(token, tagId, events, data = {}, options = {}) {
|
|
130
|
+
const { consent, pageLoads = false, dataProvider, continueOnValidationError = true } = options;
|
|
131
|
+
const capiEvents = events.filter((event) => pageLoads && event.name === "page_view" || !require_third_parties_ignored_events.IGNORED_EVENTS.includes(event.name)).map((event) => getServerEvent(event, data, consent)).filter((event) => event.eventType !== "pageLoad" || event.eventSourceUrl);
|
|
132
|
+
if (capiEvents.length === 0) return [];
|
|
133
|
+
const responses = [];
|
|
134
|
+
for (const batch of (0, _shware_utils.chunk)(capiEvents, MAX_EVENTS_PER_REQUEST)) {
|
|
135
|
+
const body = compact({
|
|
136
|
+
data: batch,
|
|
137
|
+
continueOnValidationError,
|
|
138
|
+
dataProvider
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
const response = await (0, _shware_utils.fetch)(`${ENDPOINT}/${tagId}/events`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: {
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
Accept: "application/json",
|
|
146
|
+
Authorization: `Bearer ${token}`
|
|
147
|
+
},
|
|
148
|
+
body: JSON.stringify(body)
|
|
149
|
+
});
|
|
150
|
+
if (response.ok) {
|
|
151
|
+
const result = await response.json();
|
|
152
|
+
if (result.error?.details?.length) console.warn(`Microsoft conversion accepted with validation issues: ${JSON.stringify(result.error.details)}`);
|
|
153
|
+
responses.push(result);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const { status } = response;
|
|
157
|
+
const message = await response.text();
|
|
158
|
+
console.error(`Failed to send Microsoft conversion, status: ${status}, body: ${message}`);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
161
|
+
console.error(`Failed to send Microsoft conversion, network error: ${message}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return responses;
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
exports.getServerEvent = getServerEvent;
|
|
168
|
+
exports.normalizeEmail = normalizeEmail;
|
|
169
|
+
exports.sendEvents = sendEvents;
|
|
170
|
+
|
|
171
|
+
//# sourceMappingURL=microsoft-conversions-api.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"microsoft-conversions-api.cjs","names":["createHash","formatMsclkid","mapUETEvent","pageLocation","IGNORED_EVENTS","chunk","fetch"],"sources":["../../src/server/microsoft-conversions-api.ts"],"sourcesContent":["/**\n * Microsoft Advertising Conversions API (CAPI) sender — the server-side counterpart of the UET\n * tag, in the same shape as the other senders here: a pure per-event builder, a never-throws\n * `sendEvents`, the token in a header and never in a URL or a log line.\n *\n * Microsoft's recommended deployment is hybrid: keep the UET JavaScript on the page and send the\n * same conversions here too, deduplicated by (`tagId`, `eventName`, `eventId`). Both channels in\n * this SDK are fed from the same `track()` call — the browser sends the internal name as the UET\n * action with the event id as `event_id`, and this builder sends the same name as `eventName`\n * with the same id — so the pair agrees by construction.\n *\n * The token comes from the UET tag's \"Use Conversions API\" setup step, or from the Campaign\n * Management API's `UetTagAuthKey/Query`. Access is provisioned per customer account: an account\n * whose tag setup has no \"Use Conversions API\" option has to ask Microsoft Advertising support.\n *\n * https://learn.microsoft.com/en-us/advertising/guides/uet-conversion-api-integration\n */\nimport { createHash } from 'node:crypto';\nimport { chunk, fetch } from '@shware/utils';\nimport { formatMsclkid } from '../click-id/index';\nimport { IGNORED_EVENTS } from '../third-parties/ignored-events';\nimport type { TrackEvent, UserProvidedData } from '../track/types';\nimport { mapUETEvent } from '../track/uetq';\nimport { pageLocation } from './page-location';\n\nconst ENDPOINT = 'https://capi.uet.microsoft.com/v1';\n\n/** The API's hard limit per request. */\nconst MAX_EVENTS_PER_REQUEST = 1000;\n\nconst SHA256 = /^[a-f0-9]{64}$/;\n\nconst sha256 = (value: string) => createHash('sha256').update(value).digest('hex');\n\n/**\n * Microsoft's email normalization: trim, drop the dots and any `+alias` from the user part —\n * for EVERY domain, unlike Google's gmail-only rule — lowercase, SHA-256. A value that is\n * already a SHA-256 hex digest passes through.\n * https://learn.microsoft.com/en-us/advertising/guides/uet-conversion-api-integration#hashed-identifiers\n */\nexport function normalizeEmail(input: string): string {\n const email = input.trim().toLowerCase();\n const at = email.lastIndexOf('@');\n if (at === -1) return email;\n const user = email.slice(0, at).replace(/\\./g, '').split('+', 1)[0] ?? '';\n return `${user}@${email.slice(at + 1)}`;\n}\n\nfunction hashEmail(input: string | undefined): string | undefined {\n const value = input?.trim().toLowerCase();\n if (!value) return undefined;\n return SHA256.test(value) ? value : sha256(normalizeEmail(value));\n}\n\n/**\n * Phones are E.164 by the schema (`+` then 11–15 digits, the range bat.js validates too); the\n * separators bat.js tolerates are stripped, and anything else is dropped rather than guessed —\n * a number with no country code hashed under an assumed one can never match.\n */\nfunction hashPhone(input: string | undefined): string | undefined {\n const value = input?.trim().toLowerCase();\n if (!value) return undefined;\n if (SHA256.test(value)) return value;\n const phone = value.replace(/[-() \\t]/g, '');\n return /^\\+\\d{11,15}$/.test(phone) ? sha256(phone) : undefined;\n}\n\nexport interface MicrosoftUserData {\n /** Microsoft click id, as a dashed UUID. */\n msclkid?: string;\n /** SHA-256 of the normalized email. */\n em?: string;\n /** SHA-256 of the E.164 phone number. */\n ph?: string;\n /** The SDK visitor id — what the client-side ID Sync pixel (`sendUETIdSync`) sends as `VID`. */\n anonymousId?: string;\n /** SHA-256 of the signed-in user id — what `sendUETIdSync` sends as `UID`. */\n externalId?: string;\n clientUserAgent?: string;\n clientIpAddress?: string;\n idfa?: string;\n gaid?: string;\n}\n\nexport interface MicrosoftItem {\n id?: string;\n name?: string;\n price?: number;\n quantity?: number;\n}\n\nexport interface MicrosoftCustomData {\n eventCategory?: string;\n eventLabel?: string;\n eventValue?: number;\n searchTerm?: string;\n transactionId?: string;\n value?: number;\n currency?: string;\n items?: MicrosoftItem[];\n itemIds?: string[];\n pageType?: string;\n ecommTotalValue?: number;\n ecommCategory?: string;\n}\n\n/** One entry of the `data` array POSTed to `/v1/{tagId}/events`. */\nexport interface MicrosoftEvent {\n eventType: 'custom' | 'pageLoad';\n eventId: string;\n /** The UET action; absent on `pageLoad` events. */\n eventName?: string;\n /** UNIX seconds, UTC; the API rejects anything older than 7 days. */\n eventTime: number;\n eventSourceUrl?: string;\n referrerUrl?: string;\n pageTitle?: string;\n /** `G` granted, `D` denied. Absent means granted. */\n adStorageConsent?: 'G' | 'D';\n userData: MicrosoftUserData;\n customData?: MicrosoftCustomData;\n}\n\nexport interface MicrosoftConversionsOptions {\n /**\n * EEA/UK/CH consent for the whole batch. `denied` events are accepted but used for nothing —\n * no attribution, no audiences. Absent means granted, which is also the API's default.\n */\n consent?: 'granted' | 'denied';\n /**\n * Also send `page_view` events, as CAPI `pageLoad` events. Off by default because the UET\n * JavaScript already reports every page load and the two would double-count destination-URL\n * goals; turn it on for a CAPI-only site with no tag on the page.\n *\n * Not yet modelled: `pageLoadId`, which links custom events to the page load they happened on,\n * and the revenue-only custom event a `pageLoad` needs for a destination goal with variable\n * revenue. Both matter only in this CAPI-only mode.\n */\n pageLoads?: boolean;\n /** Identifies a third-party sender in Microsoft's monitoring; leave unset for a first-party build. */\n dataProvider?: string;\n /**\n * Process the valid events of a batch and report the invalid ones, instead of the API's default\n * all-or-nothing rejection. On by default — one malformed event should cost itself, not the\n * batch it travelled with.\n */\n continueOnValidationError?: boolean;\n}\n\nexport interface MicrosoftValidationDetail {\n index?: number;\n propertyName?: string;\n attemptedValue?: unknown;\n errorMessage?: string;\n errorCode?: string;\n isWarning?: boolean;\n}\n\nexport interface MicrosoftConversionsResponse {\n eventsReceived?: number;\n error?: { code?: string; message?: string; details?: MicrosoftValidationDetail[] };\n}\n\nfunction getUserData(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData\n): MicrosoftUserData {\n const { tags, platform, visitor_id } = event;\n const email = Array.isArray(data.email) ? data.email[0] : data.email;\n const phone = Array.isArray(data.phone_number) ? data.phone_number[0] : data.phone_number;\n\n return {\n msclkid: tags.msclkid ? formatMsclkid(tags.msclkid) : undefined,\n em: hashEmail(email),\n ph: hashPhone(phone),\n // Microsoft requires this to equal the `VID` of the client-side ID Sync pixel; both sides use\n // the SDK visitor id, which every event carries and the client can always read.\n anonymousId: visitor_id || undefined,\n // Microsoft asks for an anonymized id and never a real one. SHA-256, the same digest\n // `sendUETIdSync` applies client-side, so `externalId` and the pixel's `UID` agree too.\n externalId: data.user_id ? sha256(data.user_id) : undefined,\n clientUserAgent: data.user_agent,\n clientIpAddress:\n typeof tags.ip_address === 'string' && tags.ip_address ? tags.ip_address : data.ip_address,\n idfa: platform === 'ios' ? tags.advertising_id : undefined,\n gaid: platform === 'android' ? tags.advertising_id : undefined,\n };\n}\n\n// oxlint-disable-next-line @typescript-eslint/no-explicit-any\nfunction getCustomData(event: TrackEvent<any>): MicrosoftCustomData | undefined {\n const [, params] = mapUETEvent(event.name, event.properties);\n const customData: MicrosoftCustomData = {\n eventCategory: params.event_category,\n eventLabel: params.event_label,\n eventValue: params.event_value,\n searchTerm: params.search_term,\n transactionId: params.transaction_id,\n value: params.revenue_value,\n currency: params.currency,\n items: params.items?.map(({ id, name, price, quantity }) => ({ id, name, price, quantity })),\n itemIds: Array.isArray(params.ecomm_prodid)\n ? params.ecomm_prodid\n : params.ecomm_prodid\n ? [params.ecomm_prodid]\n : undefined,\n pageType: params.ecomm_pagetype,\n ecommTotalValue: params.ecomm_totalvalue,\n ecommCategory: params.ecomm_category,\n };\n return Object.values(customData).some((value) => value !== undefined) ? customData : undefined;\n}\n\n/**\n * Build one CAPI event. `page_view` becomes a `pageLoad` event (the caller decides whether to\n * send those, see `MicrosoftConversionsOptions.pageLoads`); everything else is a `custom` event\n * named after the track event, which is also the UET action the browser sent.\n */\nexport function getServerEvent(\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n event: TrackEvent<any>,\n data: UserProvidedData = {},\n consent?: 'granted' | 'denied'\n): MicrosoftEvent {\n const { id, name, tags, created_at } = event;\n const isPageLoad = name === 'page_view';\n\n return {\n eventType: isPageLoad ? 'pageLoad' : 'custom',\n eventId: tags.idempotency_key ?? id,\n eventName: isPageLoad ? undefined : name,\n eventTime: Math.round(new Date(created_at).getTime() / 1000),\n eventSourceUrl: pageLocation(tags),\n referrerUrl: tags.page_referrer,\n pageTitle: tags.page_title,\n adStorageConsent: consent === 'granted' ? 'G' : consent === 'denied' ? 'D' : undefined,\n userData: getUserData(event, data),\n customData: isPageLoad ? undefined : getCustomData(event),\n };\n}\n\n/** Strip undefined fields so the wire payload carries only what was set. */\nfunction compact<T>(value: T): T {\n return JSON.parse(JSON.stringify(value)) as T;\n}\n\n/**\n * Send events to `POST /v1/{tagId}/events`. Batches above the API's 1,000-event limit are split\n * into consecutive requests; one response is returned per request made. Never throws: an HTTP\n * or network failure is logged (without the token) and yields no response for that request.\n */\nexport async function sendEvents(\n token: string,\n tagId: string | number,\n // oxlint-disable-next-line @typescript-eslint/no-explicit-any\n events: TrackEvent<any>[],\n data: UserProvidedData = {},\n options: MicrosoftConversionsOptions = {}\n): Promise<MicrosoftConversionsResponse[]> {\n const { consent, pageLoads = false, dataProvider, continueOnValidationError = true } = options;\n\n const capiEvents = events\n .filter(\n (event) => (pageLoads && event.name === 'page_view') || !IGNORED_EVENTS.includes(event.name)\n )\n .map((event) => getServerEvent(event, data, consent))\n // A pageLoad without a URL is rejected by the API, and a page-less event has nothing to say.\n .filter((event) => event.eventType !== 'pageLoad' || event.eventSourceUrl);\n if (capiEvents.length === 0) return [];\n\n const responses: MicrosoftConversionsResponse[] = [];\n for (const batch of chunk(capiEvents, MAX_EVENTS_PER_REQUEST)) {\n const body = compact({ data: batch, continueOnValidationError, dataProvider });\n try {\n const response = await fetch(`${ENDPOINT}/${tagId}/events`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n Accept: 'application/json',\n Authorization: `Bearer ${token}`,\n },\n body: JSON.stringify(body),\n });\n if (response.ok) {\n const result = (await response.json()) as MicrosoftConversionsResponse;\n // A 200 can still carry field-level warnings (the field was removed) and, with\n // `continueOnValidationError`, skipped events. Surface them: silent partial data loss is\n // the one failure mode the caller cannot see from the status code.\n if (result.error?.details?.length) {\n console.warn(\n `Microsoft conversion accepted with validation issues: ${JSON.stringify(result.error.details)}`\n );\n }\n responses.push(result);\n continue;\n }\n const { status } = response;\n const message = await response.text();\n console.error(`Failed to send Microsoft conversion, status: ${status}, body: ${message}`);\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n console.error(`Failed to send Microsoft conversion, network error: ${message}`);\n }\n }\n return responses;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { TrackEvent, UserProvidedData } from "../track/types.cjs";
|
|
2
|
+
//#region src/server/microsoft-conversions-api.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Microsoft's email normalization: trim, drop the dots and any `+alias` from the user part —
|
|
5
|
+
* for EVERY domain, unlike Google's gmail-only rule — lowercase, SHA-256. A value that is
|
|
6
|
+
* already a SHA-256 hex digest passes through.
|
|
7
|
+
* https://learn.microsoft.com/en-us/advertising/guides/uet-conversion-api-integration#hashed-identifiers
|
|
8
|
+
*/
|
|
9
|
+
declare function normalizeEmail(input: string): string;
|
|
10
|
+
interface MicrosoftUserData {
|
|
11
|
+
/** Microsoft click id, as a dashed UUID. */
|
|
12
|
+
msclkid?: string;
|
|
13
|
+
/** SHA-256 of the normalized email. */
|
|
14
|
+
em?: string;
|
|
15
|
+
/** SHA-256 of the E.164 phone number. */
|
|
16
|
+
ph?: string;
|
|
17
|
+
/** The SDK visitor id — what the client-side ID Sync pixel (`sendUETIdSync`) sends as `VID`. */
|
|
18
|
+
anonymousId?: string;
|
|
19
|
+
/** SHA-256 of the signed-in user id — what `sendUETIdSync` sends as `UID`. */
|
|
20
|
+
externalId?: string;
|
|
21
|
+
clientUserAgent?: string;
|
|
22
|
+
clientIpAddress?: string;
|
|
23
|
+
idfa?: string;
|
|
24
|
+
gaid?: string;
|
|
25
|
+
}
|
|
26
|
+
interface MicrosoftItem {
|
|
27
|
+
id?: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
price?: number;
|
|
30
|
+
quantity?: number;
|
|
31
|
+
}
|
|
32
|
+
interface MicrosoftCustomData {
|
|
33
|
+
eventCategory?: string;
|
|
34
|
+
eventLabel?: string;
|
|
35
|
+
eventValue?: number;
|
|
36
|
+
searchTerm?: string;
|
|
37
|
+
transactionId?: string;
|
|
38
|
+
value?: number;
|
|
39
|
+
currency?: string;
|
|
40
|
+
items?: MicrosoftItem[];
|
|
41
|
+
itemIds?: string[];
|
|
42
|
+
pageType?: string;
|
|
43
|
+
ecommTotalValue?: number;
|
|
44
|
+
ecommCategory?: string;
|
|
45
|
+
}
|
|
46
|
+
/** One entry of the `data` array POSTed to `/v1/{tagId}/events`. */
|
|
47
|
+
interface MicrosoftEvent {
|
|
48
|
+
eventType: 'custom' | 'pageLoad';
|
|
49
|
+
eventId: string;
|
|
50
|
+
/** The UET action; absent on `pageLoad` events. */
|
|
51
|
+
eventName?: string;
|
|
52
|
+
/** UNIX seconds, UTC; the API rejects anything older than 7 days. */
|
|
53
|
+
eventTime: number;
|
|
54
|
+
eventSourceUrl?: string;
|
|
55
|
+
referrerUrl?: string;
|
|
56
|
+
pageTitle?: string;
|
|
57
|
+
/** `G` granted, `D` denied. Absent means granted. */
|
|
58
|
+
adStorageConsent?: 'G' | 'D';
|
|
59
|
+
userData: MicrosoftUserData;
|
|
60
|
+
customData?: MicrosoftCustomData;
|
|
61
|
+
}
|
|
62
|
+
interface MicrosoftConversionsOptions {
|
|
63
|
+
/**
|
|
64
|
+
* EEA/UK/CH consent for the whole batch. `denied` events are accepted but used for nothing —
|
|
65
|
+
* no attribution, no audiences. Absent means granted, which is also the API's default.
|
|
66
|
+
*/
|
|
67
|
+
consent?: 'granted' | 'denied';
|
|
68
|
+
/**
|
|
69
|
+
* Also send `page_view` events, as CAPI `pageLoad` events. Off by default because the UET
|
|
70
|
+
* JavaScript already reports every page load and the two would double-count destination-URL
|
|
71
|
+
* goals; turn it on for a CAPI-only site with no tag on the page.
|
|
72
|
+
*
|
|
73
|
+
* Not yet modelled: `pageLoadId`, which links custom events to the page load they happened on,
|
|
74
|
+
* and the revenue-only custom event a `pageLoad` needs for a destination goal with variable
|
|
75
|
+
* revenue. Both matter only in this CAPI-only mode.
|
|
76
|
+
*/
|
|
77
|
+
pageLoads?: boolean;
|
|
78
|
+
/** Identifies a third-party sender in Microsoft's monitoring; leave unset for a first-party build. */
|
|
79
|
+
dataProvider?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Process the valid events of a batch and report the invalid ones, instead of the API's default
|
|
82
|
+
* all-or-nothing rejection. On by default — one malformed event should cost itself, not the
|
|
83
|
+
* batch it travelled with.
|
|
84
|
+
*/
|
|
85
|
+
continueOnValidationError?: boolean;
|
|
86
|
+
}
|
|
87
|
+
interface MicrosoftValidationDetail {
|
|
88
|
+
index?: number;
|
|
89
|
+
propertyName?: string;
|
|
90
|
+
attemptedValue?: unknown;
|
|
91
|
+
errorMessage?: string;
|
|
92
|
+
errorCode?: string;
|
|
93
|
+
isWarning?: boolean;
|
|
94
|
+
}
|
|
95
|
+
interface MicrosoftConversionsResponse {
|
|
96
|
+
eventsReceived?: number;
|
|
97
|
+
error?: {
|
|
98
|
+
code?: string;
|
|
99
|
+
message?: string;
|
|
100
|
+
details?: MicrosoftValidationDetail[];
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Build one CAPI event. `page_view` becomes a `pageLoad` event (the caller decides whether to
|
|
105
|
+
* send those, see `MicrosoftConversionsOptions.pageLoads`); everything else is a `custom` event
|
|
106
|
+
* named after the track event, which is also the UET action the browser sent.
|
|
107
|
+
*/
|
|
108
|
+
declare function getServerEvent(event: TrackEvent<any>, data?: UserProvidedData, consent?: 'granted' | 'denied'): MicrosoftEvent;
|
|
109
|
+
/**
|
|
110
|
+
* Send events to `POST /v1/{tagId}/events`. Batches above the API's 1,000-event limit are split
|
|
111
|
+
* into consecutive requests; one response is returned per request made. Never throws: an HTTP
|
|
112
|
+
* or network failure is logged (without the token) and yields no response for that request.
|
|
113
|
+
*/
|
|
114
|
+
declare function sendEvents(token: string, tagId: string | number, events: TrackEvent<any>[], data?: UserProvidedData, options?: MicrosoftConversionsOptions): Promise<MicrosoftConversionsResponse[]>;
|
|
115
|
+
//#endregion
|
|
116
|
+
export { MicrosoftConversionsOptions, MicrosoftConversionsResponse, MicrosoftCustomData, MicrosoftEvent, MicrosoftItem, MicrosoftUserData, MicrosoftValidationDetail, getServerEvent, normalizeEmail, sendEvents };
|
|
117
|
+
//# sourceMappingURL=microsoft-conversions-api.d.cts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"microsoft-conversions-api.d.cts","names":[],"sources":["../../src/server/microsoft-conversions-api.ts"],"mappings":";;;;;;;;iBAwCgB,eAAe;UA2Bd;;EAEf;;EAEA;;EAEA;;EAEA;;EAEA;EACA;EACA;EACA;EACA;;UAGe;EACf;EACA;EACA;EACA;;UAGe;EACf;EACA;EACA;EACA;EACA;EACA;EACA;EACA,QAAQ;EACR;EACA;EACA;EACA;;;UAIe;EACf;EACA;;EAEA;;EAEA;EACA;EACA;EACA;;EAEA;EACA,UAAU;EACV,aAAa;;UAGE;;;;;EAKf;;;;;;;;;;EAUA;;EAEA;;;;;;EAMA;;UAGe;EACf;EACA;EACA;EACA;EACA;EACA;;UAGe;EACf;EACA;IAAU;IAAe;IAAkB,UAAU;;;;;;;;iBA2DvC,eAEd,OAAO,iBACP,OAAM,kBACN,iCACC;;;;;;iBA4BmB,WACpB,eACA,wBAEA,QAAQ,mBACR,OAAM,kBACN,UAAS,8BACR,QAAQ"}
|