Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createTestIntegration, DynamicFieldResponse } from '@segment/actions-core'
import { Features } from '@segment/actions-core/mapping-kit'
import nock from 'nock'
import { CANARY_API_VERSION, formatToE164, commonEmailValidation, convertTimestamp } from '../functions'
import { CANARY_API_VERSION, formatToE164, commonEmailValidation, convertTimestamp, timestampToEpochMicroseconds } from '../functions'
import destination from '../index'

const testDestination = createTestIntegration(destination)
Expand Down Expand Up @@ -192,3 +192,18 @@ describe('convertTimestamp', () => {
expect(result).toEqual('2025-03-11 17:57:29+00:00')
})
})

describe('timestampToEpochMicroseconds', () => {
it('should convert timestamp with milliseconds to epoch microseconds', () => {
const timestamp = '2025-10-31T12:13:51.053Z'
const result = timestampToEpochMicroseconds(timestamp)
expect(result).toEqual('1761912831053000')
})

it('should return undefined for bad timestamps', () => {
const timestamp = 'I AM NOT A TIMESTAMP - BLEEP BLOOP'
const result = timestampToEpochMicroseconds(timestamp)
expect(result).toEqual(undefined)
})
})

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import {
UserListResponse,
UserList,
OfflineUserJobPayload,
AddOperationPayload
AddOperationPayload,
KeyValuePairList,
KeyValueItem
} from './types'
import {
ModifiedResponse,
Expand All @@ -30,6 +32,8 @@ import { StatsContext } from '@segment/actions-core/destination-kit'
import { fullFormats } from 'ajv-formats/dist/formats'
import { HTTPError } from '@segment/actions-core'
import type { Payload as UserListPayload } from './userList/generated-types'
import type { Payload as ClickConversionPayload } from './uploadClickConversion/generated-types'
import type { Payload as ClickConversionPayload2 } from './uploadClickConversion2/generated-types'
import { RefreshTokenResponse } from '.'
import { STATUS_CODE_MAPPING } from './constants'
import { processHashing } from '../../lib/hashing-utils'
Expand All @@ -39,6 +43,8 @@ export const FLAGON_NAME = 'google-enhanced-canary-version'
export const FLAGON_NAME_PHONE_VALIDATION_CHECK = 'google-enhanced-phone-validation-check'
import { PhoneNumberUtil, PhoneNumberFormat } from 'google-libphonenumber'



const phoneUtil = PhoneNumberUtil.getInstance()

type GoogleAdsErrorData = {
Expand Down Expand Up @@ -219,6 +225,17 @@ export function convertTimestamp(timestamp: string | undefined): string | undefi
return timestamp.replace(/T/, ' ').replace(/(\.\d+)?Z/, '+00:00')
}

export function timestampToEpochMicroseconds(timestamp: string): string | undefined {
if(!timestamp){
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing space after 'if' keyword. According to JavaScript/TypeScript style conventions, there should be a space between 'if' and the opening parenthesis. Change if(!timestamp) to if (!timestamp).

Suggested change
if(!timestamp){
if (!timestamp){

Copilot uses AI. Check for mistakes.
return undefined
}
const date = new Date(timestamp)
if (!isNaN(date.getTime())) {
return (date.getTime() * 1000).toString()
}
return undefined
}

export function getApiVersion(features?: Features, statsContext?: StatsContext): string {
const statsClient = statsContext?.statsClient
const tags = statsContext?.tags
Expand Down Expand Up @@ -1088,3 +1105,44 @@ export const handleJobExecutionError = (
}
})
}

export function getSessionAttributesKeyValuePairs(payload: ClickConversionPayload | ClickConversionPayload2) {
const {
session_attributes_encoded,
session_attributes_key_value_pairs: {
gad_source,
gad_campaignid,
landing_page_url,
session_start_time_usec,
landing_page_referrer,
landing_page_user_agent
} = {}
} = payload

const sessionStartTimeUsec = typeof session_start_time_usec === 'string'
? timestampToEpochMicroseconds(session_start_time_usec)
: undefined

const entries: [KeyValueItem['sessionAttributeKey'], string | undefined][] = [
['gad_source', gad_source],
['gad_campaignid', gad_campaignid],
['landing_page_url', landing_page_url],
['session_start_time_usec', sessionStartTimeUsec],
['landing_page_referrer', landing_page_referrer],
['landing_page_user_agent', landing_page_user_agent]
]

const keyValuePairList: KeyValuePairList = entries
.filter(
([_, value]) => value !== undefined && value !== null && value !== ''
)
.map(([key, value]) => ({
sessionAttributeKey: key,
sessionAttributeValue: value
}))

return (!session_attributes_encoded && keyValuePairList.length > 0
? { sessionAttributesKeyValuePairs: { keyValuePairs: keyValuePairList } }
: {}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ export interface ConversionAdjustmentRequestObjectInterface {
userAgent: string | undefined
restatementValue?: RestatementValueInterface
}

export interface ClickConversionRequestObjectInterface {
cartData: CartDataInterface | undefined
consent?: ConsentInterface
Expand All @@ -93,11 +92,28 @@ export interface ClickConversionRequestObjectInterface {
gclid: string | undefined
gbraid: string | undefined
wbraid: string | undefined
sessionAttributesEncoded: string | undefined
userIpAddress?: string
sessionAttributesEncoded?: string
sessionAttributesKeyValuePairs?: {
keyValuePairs: KeyValuePairList
}
orderId: string | undefined
userIdentifiers: UserIdentifierInterface[]
}

export type KeyValuePairList = Array<KeyValueItem>

export type KeyValueItem = {
sessionAttributeKey:
'gad_source'
| 'gad_campaignid'
| 'landing_page_url'
| 'session_start_time_usec'
| 'landing_page_referrer'
| 'landing_page_user_agent'
Comment on lines +107 to +113
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Inconsistent formatting of union type. The first line sessionAttributeKey: has trailing whitespace after the colon. For consistency and cleaner code, the union type definition should start on the same line as the property name or have consistent indentation. Consider reformatting to either sessionAttributeKey: 'gad_source' | 'gad_campaignid' | ... on one line or ensure consistent alignment.

Suggested change
sessionAttributeKey:
'gad_source'
| 'gad_campaignid'
| 'landing_page_url'
| 'session_start_time_usec'
| 'landing_page_referrer'
| 'landing_page_user_agent'
sessionAttributeKey: 'gad_source' | 'gad_campaignid' | 'landing_page_url' | 'session_start_time_usec' | 'landing_page_referrer' | 'landing_page_user_agent'

Copilot uses AI. Check for mistakes.
sessionAttributeValue?: string
}

export interface ConversionActionId {
conversionAction: {
resourceName: string
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {
getApiVersion,
commonEmailValidation,
getConversionActionDynamicData,
formatPhone
formatPhone,
getSessionAttributesKeyValuePairs
} from '../functions'
import { GOOGLE_ENHANCED_CONVERSIONS_BATCH_SIZE } from '../constants'
import { processHashing } from '../../../lib/hashing-utils'
Expand Down Expand Up @@ -55,11 +56,89 @@ const action: ActionDefinition<Settings, Payload> = {
'The click identifier for clicks associated with web conversions and originating from iOS devices starting with iOS14.',
type: 'string'
},
user_ip_address: {
label: 'User IP Address',
description: 'The IP address of the user who initiated the conversion.',
type: 'string',
default: {
'@path': '$.context.ip'
}
},
session_attributes_encoded: {
label: 'Session Attributes (Encoded)',
description:
"A base64url-encoded JSON string containing session attributes collected from the user's browser. This provides additional attribution context if gclid, gbraid, or user identifiers are missing.",
type: 'string'
"A base64url-encoded JSON string containing session attributes collected from the user's browser. Provides additional attribution context if gclid, gbraid, or user identifiers are missing. ",
type: 'string',
default: {
'@path': '$.integrations.Google Ads Conversions.session_attributes_encoded'
}
},
session_attributes_key_value_pairs: {
label: 'Session Attributes (Key Value Pairs)',
description:
"An alternative to the 'Session Attributes (Encoded)' field which can be used for Offline Conversions. If both 'Session Attributes (Encoded)' and 'Session Attributes (Key Value Pairs)' are provided, the encoded field takes precedence.",
type: 'object',
additionalProperties: false,
defaultObjectUI: 'keyvalue',
properties: {
gad_source: {
label: 'GAD Source',
description:
"An aggregate parameter served in the URL to identify the source of traffic originating from ads. See [Google's docs](https://support.google.com/google-ads/answer/16193746?sjid=2692215861659291994)",
type: 'string'
},
gad_campaignid: {
label: 'GAD Campaign ID',
description:
"The ID of the specific ad campaign that drove the ad click. See [Google's docs](https://support.google.com/google-ads/answer/16193746?sjid=2692215861659291994)",
type: 'string'
},
landing_page_url: {
label: 'Landing Page URL',
description:
'The full URL of the landing page on your website. This indicates the specific page the user first arrived on.',
type: 'string'
},
session_start_time_usec: {
label: 'Session Start Time',
description:
"The timestamp of when the user's session began on your website. This helps track the duration of user visits. The format should be a full ISO 8601 string. For example \"2025-11-18T08:52:17.023Z\".",
type: 'string',
format: 'date-time'
},
landing_page_referrer: {
label: 'Landing Page Referrer',
description:
"The URL of the webpage that linked the user to your website. This helps understand the traffic sources leading to your site. See [Google's docs](https://support.google.com/google-ads/answer/2382957?sjid=658827203196258052)",
type: 'string'
},
landing_page_user_agent: {
label: 'Landing Page User Agent',
description:
"A string that identifies the user's browser and operating system. This information can be useful for understanding the technical environment of your users.",
type: 'string'
}
},
default: {
gad_source: {
'@path': '$.properties.gad_source'
},
gad_campaignid: {
'@path': '$.properties.gad_campaignid'
},
landing_page_url: {
'@path': '$.context.page.url'
},
session_start_time_usec: {
'@path': '$.properties.session_start_time_usec'
},
landing_page_referrer: {
'@path': '$.context.page.referrer'
},
landing_page_user_agent: {
'@path': '$.context.userAgent'
}
}
},
conversion_timestamp: {
label: 'Conversion Timestamp',
Expand Down Expand Up @@ -279,13 +358,17 @@ const action: ActionDefinition<Settings, Payload> = {
})
}

const { session_attributes_encoded, user_ip_address } = payload

const request_object: ClickConversionRequestObjectInterface = {
Comment on lines +361 to 363
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] For consistency with the codebase, consider whether the destructuring of session_attributes_encoded and user_ip_address should occur inline within the spread operators where they're used (lines 369-371), rather than as separate variable declarations. This would avoid creating intermediate variables that are only used once and make the intent clearer that these are conditional includes.

Copilot uses AI. Check for mistakes.
conversionAction: `customers/${settings.customerId}/conversionActions/${payload.conversion_action}`,
conversionDateTime: convertTimestamp(payload.conversion_timestamp),
gclid: payload.gclid,
gbraid: payload.gbraid,
wbraid: payload.wbraid,
sessionAttributesEncoded: payload.session_attributes_encoded,
...(user_ip_address ? { userIpAddress: user_ip_address } : {}),
...(session_attributes_encoded ? { sessionAttributesEncoded: session_attributes_encoded } : {}),
...getSessionAttributesKeyValuePairs(payload),
orderId: payload.order_id,
conversionValue: payload.value,
currencyCode: payload.currency,
Expand Down Expand Up @@ -386,13 +469,17 @@ const action: ActionDefinition<Settings, Payload> = {
})
}

const { session_attributes_encoded, user_ip_address } = payload

const request_object: ClickConversionRequestObjectInterface = {
conversionAction: `customers/${customerId}/conversionActions/${payload.conversion_action}`,
conversionDateTime: convertTimestamp(payload.conversion_timestamp),
gclid: payload.gclid,
gbraid: payload.gbraid,
wbraid: payload.wbraid,
sessionAttributesEncoded: payload.session_attributes_encoded,
...(user_ip_address ? { userIpAddress: user_ip_address } : {}),
...(session_attributes_encoded ? { sessionAttributesEncoded: session_attributes_encoded } : {}),
...getSessionAttributesKeyValuePairs(payload),
orderId: payload.order_id,
conversionValue: payload.value,
currencyCode: payload.currency,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading