Skip to content

Commit

Permalink
Google Ads Enhanced Conversions: preventing double hashing (#2053)
Browse files Browse the repository at this point in the history
* - Preventing already hashed information to be hashed again;
- Better typing for all actions.

* Unit tests.

* Checking whether the phone is hashed also for `uploadClickConversion` action.
  • Loading branch information
seg-leonelsanches authored Jun 4, 2024
1 parent 7f24d61 commit c149f44
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -352,13 +352,14 @@ describe('GoogleEnhancedConversions', () => {
expect(responses[0].status).toBe(201)
})

it('hashed email', async () => {
it('hashed email and phone', async () => {
const event = createTestEvent({
timestamp,
event: 'Test Event',
properties: {
gclid: '54321',
email: '87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674', //'[email protected]',
email: '87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674', //'[email protected]'
phone: '1dba01a96da19f6df771cff07e0a8d822126709b82ae7adc6a3839b3aaa68a16', // '6161729102'
orderId: '1234',
total: '200',
currency: 'USD',
Expand Down Expand Up @@ -389,7 +390,7 @@ describe('GoogleEnhancedConversions', () => {
})

expect(responses[0].options.body).toMatchInlineSnapshot(
`"{\\"conversions\\":[{\\"conversionAction\\":\\"customers/1234/conversionActions/12345\\",\\"conversionDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"orderId\\":\\"1234\\",\\"conversionValue\\":200,\\"currencyCode\\":\\"USD\\",\\"cartData\\":{\\"items\\":[{\\"productId\\":\\"1234\\",\\"quantity\\":3,\\"unitPrice\\":10.99}]},\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"}],\\"consent\\":{\\"adPersonalization\\":\\"GRANTED\\"}}],\\"partialFailure\\":true}"`
`"{\\"conversions\\":[{\\"conversionAction\\":\\"customers/1234/conversionActions/12345\\",\\"conversionDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"orderId\\":\\"1234\\",\\"conversionValue\\":200,\\"currencyCode\\":\\"USD\\",\\"cartData\\":{\\"items\\":[{\\"productId\\":\\"1234\\",\\"quantity\\":3,\\"unitPrice\\":10.99}]},\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"},{\\"hashedPhoneNumber\\":\\"1dba01a96da19f6df771cff07e0a8d822126709b82ae7adc6a3839b3aaa68a16\\"}],\\"consent\\":{\\"adPersonalization\\":\\"GRANTED\\"}}],\\"partialFailure\\":true}"`
)

expect(responses.length).toBe(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,64 @@ describe('GoogleEnhancedConversions', () => {
expect(responses[0].status).toBe(201)
})

it('sends an event with default mappings, hashed data should not be hashed again', async () => {
const event = createTestEvent({
timestamp,
event: 'Test Event',
properties: {
gclid: '54321',
email: '87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674',
orderId: '1234',
phone: 'c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646',
firstName: '4f23798d92708359b734a18172c9c864f1d48044a754115a0d4b843bca3a5332',
lastName: 'fd53ef835b15485572a6e82cf470dcb41fd218ae5751ab7531c956a2a6bcd3c7',
currency: 'USD',
value: '123',
address: {
street: '123 Street SW',
city: 'San Diego',
state: 'CA',
postalCode: '982004'
}
}
})

nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}:uploadConversionAdjustments`)
.post('')
.reply(201, { results: [{}] })

const responses = await testDestination.testAction('uploadConversionAdjustment', {
event,
mapping: {
gclid: {
'@path': '$.properties.gclid'
},
conversion_action: '12345',
adjustment_type: 'ENHANCEMENT',
conversion_timestamp: {
'@path': '$.timestamp'
},
restatement_value: {
'@path': '$.properties.value'
},
restatement_currency_code: {
'@path': '$.properties.currency'
}
},
useDefaultMappings: true,
settings: {
customerId
}
})

expect(responses[0].options.body).toMatchInlineSnapshot(
`"{\\"conversionAdjustments\\":[{\\"conversionAction\\":\\"customers/1234/conversionActions/12345\\",\\"adjustmentType\\":\\"ENHANCEMENT\\",\\"adjustmentDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"orderId\\":\\"1234\\",\\"gclidDateTimePair\\":{\\"gclid\\":\\"54321\\",\\"conversionDateTime\\":\\"2021-06-10 18:08:04+00:00\\"},\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"},{\\"hashedPhoneNumber\\":\\"c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646\\"},{\\"addressInfo\\":{\\"hashedFirstName\\":\\"4f23798d92708359b734a18172c9c864f1d48044a754115a0d4b843bca3a5332\\",\\"hashedLastName\\":\\"fd53ef835b15485572a6e82cf470dcb41fd218ae5751ab7531c956a2a6bcd3c7\\"}}],\\"userAgent\\":\\"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1\\",\\"restatementValue\\":{\\"adjustedValue\\":123,\\"currencyCode\\":\\"USD\\"}}],\\"partialFailure\\":true}"`
)

expect(responses.length).toBe(1)
expect(responses[0].status).toBe(201)
})

it('fails if customerId not set', async () => {
const event = createTestEvent({
timestamp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
PartialErrorResponse,
QueryResponse,
ConversionActionId,
ConversionActionResponse
ConversionActionResponse,
CustomVariableInterface
} from './types'
import {
ModifiedResponse,
Expand All @@ -17,6 +18,7 @@ import { StatsContext } from '@segment/actions-core/destination-kit'
import { Features } from '@segment/actions-core/mapping-kit'
import { fullFormats } from 'ajv-formats/dist/formats'
import { HTTPError } from '@segment/actions-core'

export const API_VERSION = 'v15'
export const CANARY_API_VERSION = 'v15'
export const FLAGON_NAME = 'google-enhanced-canary-version'
Expand All @@ -31,9 +33,9 @@ export class GoogleAdsError extends HTTPError {
export function formatCustomVariables(
customVariables: object,
customVariableIdsResults: Array<ConversionCustomVariable>
): object {
): CustomVariableInterface[] {
// Maps custom variable keys to their resource names
const resourceNames: { [key: string]: any } = {}
const resourceNames: { [key: string]: string } = {}
Object.entries(customVariableIdsResults).forEach(([_, customVariablesIds]) => {
resourceNames[customVariablesIds.conversionCustomVariable.name] =
customVariablesIds.conversionCustomVariable.resourceName
Expand Down Expand Up @@ -170,9 +172,9 @@ export function getApiVersion(features?: Features, statsContext?: StatsContext):
return version
}

export const isHashedEmail = (email: string): boolean => new RegExp(/[0-9abcdef]{64}/gi).test(email)
export const isHashedInformation = (information: string): boolean => new RegExp(/[0-9abcdef]{64}/gi).test(information)
export const commonHashedEmailValidation = (email: string): string => {
if (isHashedEmail(email)) {
if (isHashedInformation(email)) {
return email
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface CartItem {
export interface CartItemInterface {
productId?: string
quantity?: number
unitPrice?: number
Expand All @@ -12,6 +12,88 @@ export interface ConversionCustomVariable {
}
}

export interface GclidDateTimePairInterface {
gclid: string | undefined
conversionDateTime: string | undefined
}

export interface UserIdentifierInterface {
hashedEmail?: string
hashedPhoneNumber?: string
addressInfo?: AddressInfoInterface
}

export interface AddressInfoInterface {
hashedFirstName: string | undefined
hashedLastName: string | undefined
hashedStreetAddress: string | undefined
city: string | undefined
state: string | undefined
postalCode: string | undefined
countryCode: string | undefined
}

export interface RestatementValueInterface {
adjustedValue: number | undefined
currencyCode: string | undefined
}

export interface CartDataInterface {
merchantId: string | undefined
feedCountryCode: string | undefined
feedLanguageCode: string | undefined
localTransactionCost: number | undefined
items: CartItemInterface[]
}

export interface ConsentInterface {
adUserData?: string
adPersonalization?: string
}

export interface CustomVariableInterface {
conversionCustomVariable: string
value: string
}

export interface CallConversionRequestObjectInterface {
conversionAction: string
callerId: string
callStartDateTime: string | undefined
consent?: ConsentInterface
conversionDateTime: string | undefined
conversionValue: number | undefined
currencyCode: string | undefined
customVariables?: CustomVariableInterface[]
}

export interface ConversionAdjustmentRequestObjectInterface {
adjustmentType: string
adjustmentDateTime: string | undefined
conversionAction: string
orderId: string | undefined
gclidDateTimePair: GclidDateTimePairInterface | undefined
userIdentifiers: UserIdentifierInterface[]
userAgent: string | undefined
restatementValue?: RestatementValueInterface
}

export interface ClickConversionRequestObjectInterface {
cartData: CartDataInterface | undefined
consent?: ConsentInterface
conversionAction: string
conversionDateTime: string | undefined
conversionEnvironment: string | undefined
conversionValue: number | undefined
currencyCode: string | undefined
customVariables?: CustomVariableInterface[]
gclid: string | undefined
gbraid: string | undefined
wbraid: string | undefined
orderId: string | undefined
userIdentifiers: UserIdentifierInterface[]
}

export interface ConversionActionId {
conversionAction: {
resourceName: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
handleGoogleErrors,
getConversionActionDynamicData
} from '../functions'
import { PartialErrorResponse } from '../types'
import { CallConversionRequestObjectInterface, PartialErrorResponse } from '../types'
import { ModifiedResponse } from '@segment/actions-core'

const action: ActionDefinition<Settings, Payload> = {
Expand Down Expand Up @@ -114,7 +114,7 @@ const action: ActionDefinition<Settings, Payload> = {

settings.customerId = settings.customerId.replace(/-/g, '')

const request_object: { [key: string]: any } = {
const request_object: CallConversionRequestObjectInterface = {
conversionAction: `customers/${settings.customerId}/conversionActions/${payload.conversion_action}`,
callerId: payload.caller_id,
callStartDateTime: convertTimestamp(payload.call_timestamp),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {
} from '@segment/actions-core'
import type { Settings } from '../generated-types'
import type { Payload } from './generated-types'
import { CartItem, PartialErrorResponse } from '../types'
import {
CartItemInterface,
PartialErrorResponse,
ClickConversionRequestObjectInterface,
UserIdentifierInterface
} from '../types'
import {
formatCustomVariables,
hash,
Expand All @@ -16,7 +21,8 @@ import {
convertTimestamp,
getApiVersion,
commonHashedEmailValidation,
getConversionActionDynamicData
getConversionActionDynamicData,
isHashedInformation
} from '../functions'

const action: ActionDefinition<Settings, Payload> = {
Expand Down Expand Up @@ -233,18 +239,18 @@ const action: ActionDefinition<Settings, Payload> = {
}
settings.customerId = settings.customerId.replace(/-/g, '')

let cartItems: CartItem[] = []
let cartItems: CartItemInterface[] = []
if (payload.items) {
cartItems = payload.items.map((product) => {
return {
productId: product.product_id,
quantity: product.quantity,
unitPrice: product.price
} as CartItem
} as CartItemInterface
})
}

const request_object: { [key: string]: any } = {
const request_object: ClickConversionRequestObjectInterface = {
conversionAction: `customers/${settings.customerId}/conversionActions/${payload.conversion_action}`,
conversionDateTime: convertTimestamp(payload.conversion_timestamp),
gclid: payload.gclid,
Expand Down Expand Up @@ -294,11 +300,13 @@ const action: ActionDefinition<Settings, Payload> = {

request_object.userIdentifiers.push({
hashedEmail: validatedEmail
})
} as UserIdentifierInterface)
}

if (payload.phone_number) {
request_object.userIdentifiers.push({ hashedPhoneNumber: hash(payload.phone_number) })
request_object.userIdentifiers.push({
hashedPhoneNumber: isHashedInformation(payload.phone_number) ? payload.phone_number : hash(payload.phone_number)
} as UserIdentifierInterface)
}

const response: ModifiedResponse<PartialErrorResponse> = await request(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import {
convertTimestamp,
getApiVersion,
commonHashedEmailValidation,
getConversionActionDynamicData
getConversionActionDynamicData,
isHashedInformation
} from '../functions'
import type { Settings } from '../generated-types'
import type { Payload } from './generated-types'
import { PartialErrorResponse } from '../types'
import { PartialErrorResponse, ConversionAdjustmentRequestObjectInterface, UserIdentifierInterface } from '../types'
import { ModifiedResponse } from '@segment/actions-core'

const action: ActionDefinition<Settings, Payload> = {
Expand Down Expand Up @@ -247,7 +248,7 @@ const action: ActionDefinition<Settings, Payload> = {
payload.adjustment_timestamp = new Date().toISOString()
}

const request_object: { [key: string]: any } = {
const request_object: ConversionAdjustmentRequestObjectInterface = {
conversionAction: `customers/${settings.customerId}/conversionActions/${payload.conversion_action}`,
adjustmentType: payload.adjustment_type,
adjustmentDateTime: convertTimestamp(payload.adjustment_timestamp),
Expand All @@ -273,11 +274,13 @@ const action: ActionDefinition<Settings, Payload> = {

request_object.userIdentifiers.push({
hashedEmail: validatedEmail
})
} as UserIdentifierInterface)
}

if (payload.phone_number) {
request_object.userIdentifiers.push({ hashedPhoneNumber: hash(payload.phone_number) })
request_object.userIdentifiers.push({
hashedPhoneNumber: isHashedInformation(payload.phone_number) ? payload.phone_number : hash(payload.phone_number)
} as UserIdentifierInterface)
}

const containsAddressInfo =
Expand All @@ -292,9 +295,13 @@ const action: ActionDefinition<Settings, Payload> = {
if (containsAddressInfo) {
request_object.userIdentifiers.push({
addressInfo: {
hashedFirstName: hash(payload.first_name),
hashedLastName: hash(payload.last_name),
hashedStreetAddress: hash(payload.street_address),
hashedFirstName: isHashedInformation(String(payload.first_name))
? payload.first_name
: hash(payload.first_name),
hashedLastName: isHashedInformation(String(payload.last_name)) ? payload.last_name : hash(payload.last_name),
hashedStreetAddress: isHashedInformation(String(payload.street_address))
? payload.street_address
: hash(payload.street_address),
city: payload.city,
state: payload.state,
countryCode: payload.country,
Expand Down

0 comments on commit c149f44

Please sign in to comment.