feat(cdp): add linkedin integration (#26282)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Marcus Hof
2025-01-31 16:33:32 +01:00
committed by GitHub
parent fa90a50dfb
commit 6093ac457f
16 changed files with 599 additions and 5 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -69,6 +69,8 @@ import {
HogFunctionTypeType,
InsightModel,
IntegrationType,
LinkedInAdsAccountType,
LinkedInAdsConversionRuleType,
ListOrganizationMembersParams,
LogEntry,
LogEntryRequestParams,
@@ -820,6 +822,21 @@ class ApiRequest {
.withQueryString({ customerId })
}
public integrationLinkedInAdsAccounts(id: IntegrationType['id'], teamId?: TeamType['id']): ApiRequest {
return this.integrations(teamId).addPathComponent(id).addPathComponent('linkedin_ads_accounts')
}
public integrationLinkedInAdsConversionRules(
id: IntegrationType['id'],
accountId: string,
teamId?: TeamType['id']
): ApiRequest {
return this.integrations(teamId)
.addPathComponent(id)
.addPathComponent('linkedin_ads_conversion_rules')
.withQueryString({ accountId })
}
public media(teamId?: TeamType['id']): ApiRequest {
return this.projectsDetail(teamId).addPathComponent('uploaded_media')
}
@@ -2562,6 +2579,15 @@ const api = {
): Promise<{ conversionActions: GoogleAdsConversionActionType[] }> {
return await new ApiRequest().integrationGoogleAdsConversionActions(id, customerId).get()
},
async linkedInAdsAccounts(id: IntegrationType['id']): Promise<{ adAccounts: LinkedInAdsAccountType[] }> {
return await new ApiRequest().integrationLinkedInAdsAccounts(id).get()
},
async linkedInAdsConversionRules(
id: IntegrationType['id'],
accountId: string
): Promise<{ conversionRules: LinkedInAdsConversionRuleType[] }> {
return await new ApiRequest().integrationLinkedInAdsConversionRules(id, accountId).get()
},
},
resourcePermissions: {

View File

@@ -0,0 +1,151 @@
import { LemonInputSelect, LemonInputSelectOption } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { useEffect, useMemo } from 'react'
import { IntegrationType, LinkedInAdsAccountType, LinkedInAdsConversionRuleType } from '~/types'
import { linkedInAdsIntegrationLogic } from './linkedInAdsIntegrationLogic'
const getLinkedInAdsAccountOptions = (
linkedInAdsAccounts?: LinkedInAdsAccountType[] | null
): LemonInputSelectOption[] | null => {
return linkedInAdsAccounts
? linkedInAdsAccounts.map((account) => ({
key: account.id.toString(),
labelComponent: (
<span className="flex items-center">
{account.name} ({account.id.toString()})
</span>
),
label: `${account.name}`,
}))
: null
}
const getLinkedInAdsConversionRuleOptions = (
linkedInAdsConversionRules?: LinkedInAdsConversionRuleType[] | null
): LemonInputSelectOption[] | null => {
return linkedInAdsConversionRules
? linkedInAdsConversionRules.map(({ id, name }) => ({
key: id.toString(),
labelComponent: (
<span className="flex items-center">
{name} ({id})
</span>
),
label: `${name} (${id})`,
}))
: null
}
export type LinkedInAdsPickerProps = {
integration: IntegrationType
value?: string
onChange?: (value: string | null) => void
disabled?: boolean
requiresFieldValue?: string
}
export function LinkedInAdsConversionRulePicker({
onChange,
value,
requiresFieldValue,
integration,
disabled,
}: LinkedInAdsPickerProps): JSX.Element {
const { linkedInAdsConversionRules, linkedInAdsConversionRulesLoading } = useValues(
linkedInAdsIntegrationLogic({ id: integration.id })
)
const { loadLinkedInAdsConversionRules } = useActions(linkedInAdsIntegrationLogic({ id: integration.id }))
const linkedInAdsConversionRuleOptions = useMemo(
() => getLinkedInAdsConversionRuleOptions(linkedInAdsConversionRules),
[linkedInAdsConversionRules]
)
useEffect(() => {
if (requiresFieldValue) {
loadLinkedInAdsConversionRules(requiresFieldValue)
}
}, [loadLinkedInAdsConversionRules, requiresFieldValue])
return (
<>
<LemonInputSelect
onChange={(val) => onChange?.(val[0] ?? null)}
value={value ? [value] : []}
onFocus={() =>
!linkedInAdsConversionRules &&
!linkedInAdsConversionRulesLoading &&
requiresFieldValue &&
loadLinkedInAdsConversionRules(requiresFieldValue)
}
disabled={disabled}
mode="single"
data-attr="select-linkedin-ads-conversion-action"
placeholder="Select a Conversion Action..."
options={
linkedInAdsConversionRuleOptions ??
(value
? [
{
key: value,
label: value,
},
]
: [])
}
loading={linkedInAdsConversionRulesLoading}
/>
</>
)
}
export function LinkedInAdsAccountIdPicker({
onChange,
value,
integration,
disabled,
}: LinkedInAdsPickerProps): JSX.Element {
const { linkedInAdsAccounts, linkedInAdsAccountsLoading } = useValues(
linkedInAdsIntegrationLogic({ id: integration.id })
)
const { loadLinkedInAdsAccounts } = useActions(linkedInAdsIntegrationLogic({ id: integration.id }))
const linkedInAdsAccountOptions = useMemo(
() => getLinkedInAdsAccountOptions(linkedInAdsAccounts),
[linkedInAdsAccounts]
)
useEffect(() => {
if (!disabled) {
loadLinkedInAdsAccounts()
}
}, [loadLinkedInAdsAccounts])
return (
<>
<LemonInputSelect
onChange={(val) => onChange?.(val[0] ?? null)}
value={value ? [value] : []}
onFocus={() => !linkedInAdsAccounts && !linkedInAdsAccountsLoading && loadLinkedInAdsAccounts()}
disabled={disabled}
mode="single"
data-attr="select-linkedin-ads-customer-id-channel"
placeholder="Select a Account ID..."
options={
linkedInAdsAccountOptions ??
(value
? [
{
key: value,
label: value.replace(/(\d{3})(\d{3})(\d{4})/, '$1-$2-$3'),
},
]
: [])
}
loading={linkedInAdsAccountsLoading}
/>
</>
)
}

View File

@@ -8,6 +8,7 @@ import IconGoogleAds from 'public/services/google-ads.png'
import IconGoogleCloud from 'public/services/google-cloud.png'
import IconGoogleCloudStorage from 'public/services/google-cloud-storage.png'
import IconHubspot from 'public/services/hubspot.png'
import IconLinkedIn from 'public/services/linkedin.png'
import IconSalesforce from 'public/services/salesforce.png'
import IconSlack from 'public/services/slack.png'
import IconSnapchat from 'public/services/snapchat.png'
@@ -26,6 +27,7 @@ const ICONS: Record<IntegrationKind, any> = {
'google-cloud-storage': IconGoogleCloudStorage,
'google-ads': IconGoogleAds,
snapchat: IconSnapchat,
'linkedin-ads': IconLinkedIn,
}
export const integrationsLogic = kea<integrationsLogicType>([

View File

@@ -0,0 +1,37 @@
import { actions, kea, key, path, props } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
import { LinkedInAdsAccountType, LinkedInAdsConversionRuleType } from '~/types'
import type { linkedInAdsIntegrationLogicType } from './linkedInAdsIntegrationLogicType'
export const linkedInAdsIntegrationLogic = kea<linkedInAdsIntegrationLogicType>([
props({} as { id: number }),
key((props) => props.id),
path((key) => ['lib', 'integrations', 'linkedInAdsIntegrationLogic', key]),
actions({
loadLinkedInAdsConversionRules: (accountId: string) => accountId,
loadLinkedInAdsAccounts: true,
}),
loaders(({ props }) => ({
linkedInAdsConversionRules: [
null as LinkedInAdsConversionRuleType[] | null,
{
loadLinkedInAdsConversionRules: async (customerId: string) => {
const res = await api.integrations.linkedInAdsConversionRules(props.id, customerId)
return res.conversionRules
},
},
],
linkedInAdsAccounts: [
null as LinkedInAdsAccountType[] | null,
{
loadLinkedInAdsAccounts: async () => {
const res = await api.integrations.linkedInAdsAccounts(props.id)
return res.adAccounts
},
},
],
})),
])

View File

@@ -5,6 +5,10 @@ import {
GoogleAdsCustomerIdPicker,
} from 'lib/integrations/GoogleAdsIntegrationHelpers'
import { integrationsLogic } from 'lib/integrations/integrationsLogic'
import {
LinkedInAdsAccountIdPicker,
LinkedInAdsConversionRulePicker,
} from 'lib/integrations/LinkedInIntegrationHelpers'
import { SlackChannelPicker } from 'lib/integrations/SlackIntegrationHelpers'
import { HogFunctionInputSchemaType } from '~/types'
@@ -99,6 +103,25 @@ export function HogFunctionInputIntegrationField({
/>
)
}
if (schema.integration_field === 'linkedin_ads_conversion_rule_id' && requiresFieldValue) {
return (
<LinkedInAdsConversionRulePicker
value={value}
requiresFieldValue={requiresFieldValue}
onChange={(x) => onChange?.(x?.split('|')[0])}
integration={integration}
/>
)
}
if (schema.integration_field === 'linkedin_ads_account_id') {
return (
<LinkedInAdsAccountIdPicker
value={value}
onChange={(x) => onChange?.(x?.split('|')[0])}
integration={integration}
/>
)
}
return (
<div className="text-danger">
<p>Unsupported integration type: {schema.integration}</p>

View File

@@ -3748,6 +3748,7 @@ export type IntegrationKind =
| 'google-pubsub'
| 'google-cloud-storage'
| 'google-ads'
| 'linkedin-ads'
| 'snapchat'
export interface IntegrationType {
@@ -4844,6 +4845,17 @@ export type GoogleAdsConversionActionType = {
resourceName: string
}
export type LinkedInAdsConversionRuleType = {
id: number
name: string
}
export type LinkedInAdsAccountType = {
id: number
name: string
campaigns: string
}
export type DataColorThemeModel = {
id: number
name: string

View File

@@ -1,5 +1,3 @@
{
"disabledLabels": [
"no-greptile"
]
"disabledLabels": ["no-greptile"]
}

View File

@@ -19,6 +19,7 @@ from posthog.models.integration import (
SlackIntegration,
GoogleCloudIntegration,
GoogleAdsIntegration,
LinkedInAdsIntegration,
)
@@ -138,3 +139,36 @@ class IntegrationViewSet(
response_data = {"accessibleAccounts": google_ads.list_google_ads_accessible_accounts()}
cache.set(key, response_data, 60)
return Response(response_data)
@action(methods=["GET"], detail=True, url_path="linkedin_ads_conversion_rules")
def linkedin_ad_conversion_rules(self, request: Request, *args: Any, **kwargs: Any) -> Response:
instance = self.get_object()
linkedin_ads = LinkedInAdsIntegration(instance)
account_id = request.query_params.get("accountId")
response = linkedin_ads.list_linkedin_ads_conversion_rules(account_id)
conversion_rules = [
{
"id": conversionRule["id"],
"name": conversionRule["name"],
}
for conversionRule in (response if isinstance(response, list) else [])
]
return Response({"conversionRules": conversion_rules})
@action(methods=["GET"], detail=True, url_path="linkedin_ads_accounts")
def linkedin_ad_accounts(self, request: Request, *args: Any, **kwargs: Any) -> Response:
instance = self.get_object()
linkedin_ads = LinkedInAdsIntegration(instance)
accounts = [
{
"id": account["id"],
"name": account["name"],
"reference": account["reference"],
}
for account in linkedin_ads.list_linkedin_ads_accounts()["elements"]
]
return Response({"adAccounts": accounts})

View File

@@ -36,6 +36,7 @@ from .google_ads.template_google_ads import template as google_ads
from .attio.template_attio import template as attio
from .mailchimp.template_mailchimp import template as mailchimp
from .microsoft_teams.template_microsoft_teams import template as microsoft_teams
from .linkedin_ads.template_linkedin_ads import template as linkedin_ads
from .klaviyo.template_klaviyo import template_user as klaviyo_user, template_event as klaviyo_event
from .google_cloud_storage.template_google_cloud_storage import (
template as google_cloud_storage,
@@ -84,6 +85,7 @@ HOG_FUNCTION_TEMPLATES = [
klaviyo_event,
klaviyo_user,
knock,
linkedin_ads,
loops,
loops_send_event,
mailchimp,

View File

@@ -0,0 +1,155 @@
from posthog.cdp.templates.hog_function_template import HogFunctionTemplate
template: HogFunctionTemplate = HogFunctionTemplate(
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"],
hog="""
let body := {
'conversion': f'urn:lla:llaPartnerConversion:{inputs.conversionRuleId}',
'conversionHappenedAt': inputs.conversionDateTime,
'conversionValue': {},
'user': {
'userIds': [],
'userInfo': {}
},
'eventId' : inputs.eventId
}
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': '202409'
},
'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

@@ -0,0 +1,66 @@
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": "202409",
},
"body": {
"conversion": "urn:lla:llaPartnerConversion:conversion-rule-12345",
"conversionHappenedAt": 1737464596570,
"conversionValue": {"currencyCode": "USD", "amount": "100"},
"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",
},
},
)
)

View File

@@ -0,0 +1,29 @@
# Generated by Django 4.2.18 on 2025-01-31 14:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("posthog", "0557_add_tags_to_experiment_saved_metrics"),
]
operations = [
migrations.AlterField(
model_name="integration",
name="kind",
field=models.CharField(
choices=[
("slack", "Slack"),
("salesforce", "Salesforce"),
("hubspot", "Hubspot"),
("google-pubsub", "Google Pubsub"),
("google-cloud-storage", "Google Cloud Storage"),
("google-ads", "Google Ads"),
("snapchat", "Snapchat"),
("linkedin-ads", "Linkedin Ads"),
],
max_length=20,
),
),
]

View File

@@ -1 +1 @@
0557_add_tags_to_experiment_saved_metrics
0558_alter_integration_kind

View File

@@ -50,6 +50,7 @@ class Integration(models.Model):
GOOGLE_CLOUD_STORAGE = "google-cloud-storage"
GOOGLE_ADS = "google-ads"
SNAPCHAT = "snapchat"
LINKEDIN_ADS = "linkedin-ads"
team = models.ForeignKey("Team", on_delete=models.CASCADE)
@@ -116,7 +117,7 @@ class OauthConfig:
class OauthIntegration:
supported_kinds = ["slack", "salesforce", "hubspot", "google-ads", "snapchat"]
supported_kinds = ["slack", "salesforce", "hubspot", "google-ads", "snapchat", "linkedin-ads"]
integration: Integration
def __init__(self, integration: Integration) -> None:
@@ -210,6 +211,21 @@ class OauthIntegration:
id_path="me.id",
name_path="me.email",
)
elif kind == "linkedin-ads":
if not settings.LINKEDIN_APP_CLIENT_ID or not settings.LINKEDIN_APP_CLIENT_SECRET:
raise NotImplementedError("LinkedIn Ads app not configured")
return OauthConfig(
authorize_url="https://www.linkedin.com/oauth/v2/authorization",
token_info_url="https://api.linkedin.com/v2/userinfo",
token_info_config_fields=["sub", "email"],
token_url="https://www.linkedin.com/oauth/v2/accessToken",
client_id=settings.LINKEDIN_APP_CLIENT_ID,
client_secret=settings.LINKEDIN_APP_CLIENT_SECRET,
scope="r_ads rw_conversions openid profile email",
id_path="sub",
name_path="email",
)
raise NotImplementedError(f"Oauth config for kind {kind} not implemented")
@@ -594,3 +610,43 @@ class GoogleCloudIntegration:
reload_integrations_on_workers(self.integration.team_id, [self.integration.id])
logger.info(f"Refreshed access token for {self}")
class LinkedInAdsIntegration:
integration: Integration
def __init__(self, integration: Integration) -> None:
if integration.kind != "linkedin-ads":
raise Exception("LinkedInAdsIntegration init called with Integration with wrong 'kind'")
self.integration = integration
@property
def client(self) -> WebClient:
return WebClient(self.integration.sensitive_config["access_token"])
def list_linkedin_ads_conversion_rules(self, account_id) -> list[dict]:
response = requests.request(
"GET",
f"https://api.linkedin.com/rest/conversions?q=account&account=urn%3Ali%3AsponsoredAccount%3A{account_id}&fields=conversionMethod%2Cenabled%2Ctype%2Cname%2Cid%2Ccampaigns%2CattributionType",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.integration.sensitive_config['access_token']}",
"LinkedIn-Version": "202409",
},
)
return response.json()
def list_linkedin_ads_accounts(self) -> dict:
response = requests.request(
"GET",
"https://api.linkedin.com/v2/adAccountsV2?q=search",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.integration.sensitive_config['access_token']}",
"LinkedIn-Version": "202409",
},
)
return response.json()

View File

@@ -9,6 +9,9 @@ SNAPCHAT_APP_CLIENT_SECRET = get_from_env("SNAPCHAT_APP_CLIENT_SECRET", "")
SALESFORCE_CONSUMER_KEY = get_from_env("SALESFORCE_CONSUMER_KEY", "")
SALESFORCE_CONSUMER_SECRET = get_from_env("SALESFORCE_CONSUMER_SECRET", "")
LINKEDIN_APP_CLIENT_ID = get_from_env("LINKEDIN_APP_CLIENT_ID", "")
LINKEDIN_APP_CLIENT_SECRET = get_from_env("LINKEDIN_APP_CLIENT_SECRET", "")
GOOGLE_ADS_APP_CLIENT_ID = get_from_env("GOOGLE_ADS_APP_CLIENT_ID", "")
GOOGLE_ADS_APP_CLIENT_SECRET = get_from_env("GOOGLE_ADS_APP_CLIENT_SECRET", "")
GOOGLE_ADS_DEVELOPER_TOKEN = get_from_env("GOOGLE_ADS_DEVELOPER_TOKEN", "")