fix(msg): no empty objects linkedin ads (#38905)

Co-authored-by: Haven <haven@posthog.com>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Marcus Hof
2025-10-02 08:14:08 +02:00
committed by GitHub
parent 6c604f4632
commit 995672ab10
6 changed files with 255 additions and 262 deletions

View File

@@ -0,0 +1,91 @@
import { DateTime, Settings } from 'luxon'
import { TemplateTester } from '../../test/test-helpers'
import { template } from './linkedin.template'
jest.setTimeout(60 * 1000)
const buildInputs = (inputs: Record<string, any> = {}): Record<string, any> => {
return {
oauth: { access_token: 'oauth-1234' },
accountId: 'account-12345',
conversionRuleId: 'conversion-rule-12345',
conversionDateTime: 1737464596570,
conversionValue: '100',
currencyCode: 'USD',
eventId: 'event-12345',
userIds: {
SHA256_EMAIL: '3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98',
LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID: 'abc',
},
userInfo: { lastName: 'AI', firstName: 'Max', companyName: 'PostHog', countryCode: 'US' },
...inputs,
}
}
describe('linkedin template', () => {
const tester = new TemplateTester(template)
beforeEach(async () => {
Settings.defaultZone = 'UTC'
await tester.beforeEach()
const fixedTime = DateTime.fromISO('2025-01-01T00:00:00Z').toJSDate()
jest.spyOn(Date, 'now').mockReturnValue(fixedTime.getTime())
})
afterEach(() => {
Settings.defaultZone = 'system'
jest.useRealTimers()
})
it('works with all properties', async () => {
const response = await tester.invoke(buildInputs())
expect(response.error).toBeUndefined()
expect(response.finished).toEqual(false)
expect(response.invocation.queueParameters).toMatchInlineSnapshot(`
{
"body": "{"conversion":"urn:lla:llaPartnerConversion:conversion-rule-12345","conversionHappenedAt":1737464596570,"user":{"userIds":[{"idType":"SHA256_EMAIL","idValue":"3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98"},{"idType":"LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID","idValue":"abc"}],"userInfo":{"lastName":"AI","firstName":"Max","companyName":"PostHog","countryCode":"US"}},"eventId":"event-12345","conversionValue":{"currencyCode":"USD","amount":"100"}}",
"headers": {
"Authorization": "Bearer oauth-1234",
"Content-Type": "application/json",
"LinkedIn-Version": "202508",
},
"method": "POST",
"type": "fetch",
"url": "https://api.linkedin.com/rest/conversionEvents",
}
`)
const fetchResponse = await tester.invokeFetchResponse(response.invocation, {
status: 200,
body: { status: 'OK' },
})
expect(fetchResponse.finished).toBe(true)
expect(fetchResponse.error).toBeUndefined()
})
it('does not contain empty objects', async () => {
const response = await tester.invoke(
buildInputs({
conversionValue: null,
currencyCode: null,
userInfo: {},
})
)
expect(response.invocation.queueParameters).toMatchInlineSnapshot(`
{
"body": "{"conversion":"urn:lla:llaPartnerConversion:conversion-rule-12345","conversionHappenedAt":1737464596570,"user":{"userIds":[{"idType":"SHA256_EMAIL","idValue":"3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98"},{"idType":"LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID","idValue":"abc"}]},"eventId":"event-12345"}",
"headers": {
"Authorization": "Bearer oauth-1234",
"Content-Type": "application/json",
"LinkedIn-Version": "202508",
},
"method": "POST",
"type": "fetch",
"url": "https://api.linkedin.com/rest/conversionEvents",
}
`)
})
})

View File

@@ -0,0 +1,162 @@
import { HogFunctionTemplate } from '~/cdp/types'
export const template: HogFunctionTemplate = {
free: false,
status: 'alpha',
type: 'destination',
id: 'template-linkedin-ads',
name: 'LinkedIn Ads Conversions',
description: 'Send conversion events to LinkedIn Ads',
icon_url: '/static/services/linkedin.png',
category: ['Advertisement'],
code_language: 'hog',
code: `
let body := {
'conversion': f'urn:lla:llaPartnerConversion:{inputs.conversionRuleId}',
'conversionHappenedAt': inputs.conversionDateTime,
'user': {
'userIds': []
},
'eventId' : inputs.eventId
}
if (not empty(inputs.conversionValue) or not empty(inputs.currencyCode)) {
body.conversionValue := {}
}
if (not empty(inputs.currencyCode)) {
body.conversionValue.currencyCode := inputs.currencyCode
}
if (not empty(inputs.conversionValue)) {
body.conversionValue.amount := inputs.conversionValue
}
let userInfo := {}
for (let key, value in inputs.userInfo) {
if (not empty(value)) {
userInfo[key] := value
}
}
if (length(keys(userInfo)) >= 1) {
body.user['userInfo'] := userInfo
}
for (let key, value in inputs.userIds) {
if (not empty(value)) {
body.user.userIds := arrayPushBack(body.user.userIds, {'idType': key, 'idValue': value})
}
}
let res := fetch('https://api.linkedin.com/rest/conversionEvents', {
'method': 'POST',
'headers': {
'Authorization': f'Bearer {inputs.oauth.access_token}',
'Content-Type': 'application/json',
'LinkedIn-Version': '202508'
},
'body': body
})
if (res.status >= 400) {
throw Error(f'Error from api.linkedin.com (status {res.status}): {res.body}')
}
`,
inputs_schema: [
{
key: 'oauth',
type: 'integration',
integration: 'linkedin-ads',
label: 'LinkedIn Ads account',
secret: false,
required: true,
},
{
key: 'accountId',
type: 'integration_field',
integration_key: 'oauth',
integration_field: 'linkedin_ads_account_id',
label: 'Account ID',
description: 'ID of your LinkedIn Ads Account. This should be 9-digits and in XXXXXXXXX format.',
secret: false,
required: true,
},
{
key: 'conversionRuleId',
type: 'integration_field',
integration_key: 'oauth',
integration_field: 'linkedin_ads_conversion_rule_id',
requires_field: 'accountId',
label: 'Conversion rule',
description: 'The Conversion rule associated with this conversion.',
secret: false,
required: true,
},
{
key: 'conversionDateTime',
type: 'string',
label: 'Conversion Date Time',
description:
'The timestamp at which the conversion occurred in milliseconds. Must be after the click time.',
default: '{toUnixTimestampMilli(event.timestamp)}',
secret: false,
required: true,
},
{
key: 'conversionValue',
type: 'string',
label: 'Conversion value',
description: 'The value of the conversion for the advertiser in decimal string. (e.g. “100.05”).',
default: '',
secret: false,
required: false,
},
{
key: 'currencyCode',
type: 'string',
label: 'Currency code',
description:
'Currency associated with the conversion value. This is the ISO 4217 3-character currency code. For example: USD, EUR.',
default: '',
secret: false,
required: false,
},
{
key: 'eventId',
type: 'string',
label: 'Event ID',
description: 'ID of the event that triggered the conversion.',
default: '{event.uuid}',
secret: false,
required: true,
},
{
key: 'userIds',
type: 'dictionary',
label: 'User ids',
description:
'A map that contains user ids. See this page for options: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-03&tabs=curl#idtype',
default: {
SHA256_EMAIL: '{sha256Hex(person.properties.email)}',
LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID:
'{person.properties.li_fat_id ?? person.properties.$initial_li_fat_id}',
},
secret: false,
required: true,
},
{
key: 'userInfo',
type: 'dictionary',
label: 'User information',
description:
'A map that contains user information data. See this page for options: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-03&tabs=curl#userinfo',
default: {
firstName: '{person.properties.first_name}',
lastName: '{person.properties.last_name}',
title: '{person.properties.title}',
companyName: '{person.properties.company}',
countryCode: '{person.properties.$geoip_country_code}',
},
secret: false,
required: true,
},
],
}

View File

@@ -9,6 +9,7 @@ import { template as googleTagManagerTemplate } from './_destinations/google-tag
import { template as googleAdsTemplate } from './_destinations/google_ads/google.template'
import { template as googleSheetsTemplate } from './_destinations/google_sheets/google_sheets.template'
import { template as linearTemplate } from './_destinations/linear/linear.template'
import { template as linkedinAdsTemplate } from './_destinations/linkedin_ads/linkedin.template'
import { template as nativeWebhookTemplate } from './_destinations/native_webhook/webhook.template'
import { template as posthogCaptureTemplate } from './_destinations/posthog_capture/posthog-capture.template'
import { template as posthogGroupIdentifyTemplate } from './_destinations/posthog_capture/posthog-group-identify.template'
@@ -42,6 +43,7 @@ export const HOG_FUNCTION_TEMPLATES_DESTINATIONS: HogFunctionTemplate[] = [
linearTemplate,
githubTemplate,
googleAdsTemplate,
linkedinAdsTemplate,
redditAdsTemplate,
twilioTemplate,
googleSheetsTemplate,

View File

@@ -48,7 +48,6 @@ from .klaviyo.template_klaviyo import (
template_user as klaviyo_user,
)
from .knock.template_knock import template as knock
from .linkedin_ads.template_linkedin_ads import template as linkedin_ads
from .loops.template_loops import (
TemplateLoopsMigrator,
template as loops,
@@ -112,7 +111,6 @@ HOG_FUNCTION_TEMPLATES = [
klaviyo_event,
klaviyo_user,
knock,
linkedin_ads,
loops,
loops_send_event,
mailchimp,

View File

@@ -1,159 +0,0 @@
from posthog.cdp.templates.hog_function_template import HogFunctionTemplateDC
template: HogFunctionTemplateDC = HogFunctionTemplateDC(
status="alpha",
free=False,
type="destination",
id="template-linkedin-ads",
name="LinkedIn Ads Conversions",
description="Send conversion events to LinkedIn Ads",
icon_url="/static/services/linkedin.png",
category=["Advertisement"],
code_language="hog",
code="""
let body := {
'conversion': f'urn:lla:llaPartnerConversion:{inputs.conversionRuleId}',
'conversionHappenedAt': inputs.conversionDateTime,
'user': {
'userIds': [],
'userInfo': {}
},
'eventId' : inputs.eventId
}
if (not empty(inputs.conversionValue) or not empty(inputs.currencyCode)) {
body.conversionValue := {}
}
if (not empty(inputs.currencyCode)) {
body.conversionValue.currencyCode := inputs.currencyCode
}
if (not empty(inputs.conversionValue)) {
body.conversionValue.amount := inputs.conversionValue
}
for (let key, value in inputs.userInfo) {
if (not empty(value)) {
body.user.userInfo[key] := value
}
}
for (let key, value in inputs.userIds) {
if (not empty(value)) {
body.user.userIds := arrayPushBack(body.user.userIds, {'idType': key, 'idValue': value})
}
}
let res := fetch('https://api.linkedin.com/rest/conversionEvents', {
'method': 'POST',
'headers': {
'Authorization': f'Bearer {inputs.oauth.access_token}',
'Content-Type': 'application/json',
'LinkedIn-Version': '202508'
},
'body': body
})
if (res.status >= 400) {
throw Error(f'Error from api.linkedin.com (status {res.status}): {res.body}')
}
""".strip(),
inputs_schema=[
{
"key": "oauth",
"type": "integration",
"integration": "linkedin-ads",
"label": "LinkedIn Ads account",
"secret": False,
"required": True,
},
{
"key": "accountId",
"type": "integration_field",
"integration_key": "oauth",
"integration_field": "linkedin_ads_account_id",
"label": "Account ID",
"description": "ID of your LinkedIn Ads Account. This should be 9-digits and in XXXXXXXXX format.",
"secret": False,
"required": True,
},
{
"key": "conversionRuleId",
"type": "integration_field",
"integration_key": "oauth",
"integration_field": "linkedin_ads_conversion_rule_id",
"requires_field": "accountId",
"label": "Conversion rule",
"description": "The Conversion rule associated with this conversion.",
"secret": False,
"required": True,
},
{
"key": "conversionDateTime",
"type": "string",
"label": "Conversion Date Time",
"description": "The timestamp at which the conversion occurred in milliseconds. Must be after the click time.",
"default": "{toUnixTimestampMilli(event.timestamp)}",
"secret": False,
"required": True,
},
{
"key": "conversionValue",
"type": "string",
"label": "Conversion value",
"description": "The value of the conversion for the advertiser in decimal string. (e.g. “100.05”).",
"default": "",
"secret": False,
"required": False,
},
{
"key": "currencyCode",
"type": "string",
"label": "Currency code",
"description": "Currency associated with the conversion value. This is the ISO 4217 3-character currency code. For example: USD, EUR.",
"default": "",
"secret": False,
"required": False,
},
{
"key": "eventId",
"type": "string",
"label": "Event ID",
"description": "ID of the event that triggered the conversion.",
"default": "{event.uuid}",
"secret": False,
"required": True,
},
{
"key": "userIds",
"type": "dictionary",
"label": "User ids",
"description": "A map that contains user ids. See this page for options: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-03&tabs=curl#idtype",
"default": {
"SHA256_EMAIL": "{sha256Hex(person.properties.email)}",
"LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID": "{person.properties.li_fat_id ?? person.properties.$initial_li_fat_id}",
},
"secret": False,
"required": True,
},
{
"key": "userInfo",
"type": "dictionary",
"label": "User information",
"description": "A map that contains user information data. See this page for options: https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-03&tabs=curl#userinfo",
"default": {
"firstName": "{person.properties.first_name}",
"lastName": "{person.properties.last_name}",
"title": "{person.properties.title}",
"companyName": "{person.properties.company}",
"countryCode": "{person.properties.$geoip_country_code}",
},
"secret": False,
"required": True,
},
],
filters={
"events": [],
"actions": [],
"filter_test_accounts": True,
},
)

View File

@@ -1,101 +0,0 @@
from inline_snapshot import snapshot
from posthog.cdp.templates.helpers import BaseHogFunctionTemplateTest
from posthog.cdp.templates.linkedin_ads.template_linkedin_ads import template as template_linkedin_ads
class TestTemplateLinkedInAds(BaseHogFunctionTemplateTest):
template = template_linkedin_ads
def _inputs(self, **kwargs):
inputs = {
"oauth": {
"access_token": "oauth-1234",
},
"accountId": "account-12345",
"conversionRuleId": "conversion-rule-12345",
"conversionDateTime": 1737464596570,
"conversionValue": "100",
"currencyCode": "USD",
"eventId": "event-12345",
"userIds": {
"SHA256_EMAIL": "3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98",
"LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID": "abc",
},
"userInfo": {"lastName": "AI", "firstName": "Max", "companyName": "PostHog", "countryCode": "US"},
}
inputs.update(kwargs)
return inputs
def test_function_works(self):
self.run_function(self._inputs())
assert self.get_mock_fetch_calls()[0] == snapshot(
(
"https://api.linkedin.com/rest/conversionEvents",
{
"method": "POST",
"headers": {
"Authorization": "Bearer oauth-1234",
"Content-Type": "application/json",
"LinkedIn-Version": "202508",
},
"body": {
"conversion": "urn:lla:llaPartnerConversion:conversion-rule-12345",
"conversionHappenedAt": 1737464596570,
"user": {
"userIds": [
{
"idType": "SHA256_EMAIL",
"idValue": "3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98",
},
{"idType": "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID", "idValue": "abc"},
],
"userInfo": {
"lastName": "AI",
"firstName": "Max",
"companyName": "PostHog",
"countryCode": "US",
},
},
"eventId": "event-12345",
"conversionValue": {"currencyCode": "USD", "amount": "100"},
},
},
)
)
def test_does_not_contain_an_empty_conversion_value_object(self):
self.run_function(self._inputs(conversionValue=None, currencyCode=None))
assert self.get_mock_fetch_calls()[0] == snapshot(
(
"https://api.linkedin.com/rest/conversionEvents",
{
"method": "POST",
"headers": {
"Authorization": "Bearer oauth-1234",
"Content-Type": "application/json",
"LinkedIn-Version": "202508",
},
"body": {
"conversion": "urn:lla:llaPartnerConversion:conversion-rule-12345",
"conversionHappenedAt": 1737464596570,
"user": {
"userIds": [
{
"idType": "SHA256_EMAIL",
"idValue": "3edfaed7454eedb3c72bad566901af8bfbed1181816dde6db91dfff0f0cffa98",
},
{"idType": "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID", "idValue": "abc"},
],
"userInfo": {
"lastName": "AI",
"firstName": "Max",
"companyName": "PostHog",
"countryCode": "US",
},
},
"eventId": "event-12345",
},
},
)
)