Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add reddit pixel CDP destination #28493

Merged
merged 19 commits into from
Feb 12, 2025
Merged
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
Binary file added frontend/public/services/reddit.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions posthog/cdp/templates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from ._internal.template_blank import blank_site_destination, blank_site_app
from .snapchat_ads.template_snapchat_ads import template as snapchat_ads
from .snapchat_ads.template_pixel import template_snapchat_pixel as snapchat_pixel
from .reddit.template_reddit_pixel import template_reddit_pixel as reddit_pixel


HOG_FUNCTION_TEMPLATES = [
Expand Down Expand Up @@ -97,6 +98,7 @@
meta_ads,
microsoft_teams,
posthog,
reddit_pixel,
rudderstack,
salesforce_create,
salesforce_update,
Expand Down
116 changes: 114 additions & 2 deletions posthog/cdp/templates/helpers.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import functools
import json
from collections.abc import Callable
from typing import Any, Optional, cast
from unittest.mock import MagicMock

import STPyV8

from common.hogvm.python.execute import execute_bytecode
from common.hogvm.python.stl import now
from posthog.cdp.site_functions import get_transpiled_function
from posthog.cdp.templates.hog_function_template import HogFunctionTemplate
from posthog.cdp.validation import compile_hog
from posthog.test.base import BaseTest
from common.hogvm.python.execute import execute_bytecode
from posthog.models import HogFunction
from posthog.models.utils import uuid7
from posthog.test.base import BaseTest, APIBaseTest


class BaseHogFunctionTemplateTest(BaseTest):
Expand Down Expand Up @@ -89,3 +99,105 @@ def run_function(self, inputs: dict, globals=None, functions: Optional[dict] = N
globals,
functions=final_functions,
)


class BaseSiteDestinationFunctionTest(APIBaseTest):
template: HogFunctionTemplate
track_fn: str
inputs: dict

@functools.lru_cache
def _get_transpiled(self, edit_payload: Optional[Callable[[dict], dict]] = None):
# TODO do this without calling the API. There's a lot of logic in the endpoint which would need to be extracted
payload = {
"description": self.template.description,
"enabled": True,
"filters": self.template.filters,
"icon_url": self.template.icon_url,
"inputs": self.inputs,
"mappings": [
{
"filters": m.filters,
"inputs": {i["key"]: {"value": i["default"]} for i in (m.inputs_schema or [])},
"inputs_schema": m.inputs_schema,
"name": m.name,
}
for m in (self.template.mapping_templates or [])
],
"masking": self.template.masking,
"name": self.template.name,
"template_id": self.template.id,
"type": self.template.type,
}
if edit_payload:
payload = edit_payload(payload)
response = self.client.post(
f"/api/projects/{self.team.id}/hog_functions/",
data=payload,
)
assert response.status_code in (200, 201)
function_id = response.json()["id"]

# load from the DB based on the created ID
hog_function = HogFunction.objects.get(id=function_id)

return get_transpiled_function(hog_function)

def _process_event(
self,
event_name: str,
event_properties: Optional[dict] = None,
person_properties: Optional[dict] = None,
edit_payload: Optional[Callable[[dict], dict]] = None,
):
event_id = str(uuid7())
js_globals = {
"event": {"uuid": event_id, "event": event_name, "properties": event_properties or {}, "timestamp": now()},
"person": {"properties": person_properties or {}},
"groups": {},
}
# We rely on the fact that most tracking scripts have idempotent init functions.
# This means that we can add our own tracking function first, and the regular init code (which typically adds an HTML script element) won't run.
# This lets us run the processEvent code in a minimal JS environment, and capture the outputs for given inputs.
js = f"""
{JS_STDLIB}

const calls = [];
const {self.track_fn} = (...args) => calls.push(args);
window.{self.track_fn} = {self.track_fn};

const globals = {json.dumps(js_globals)};
const posthog = {{
get_property: (key) => key === '$stored_person_properties' ? globals.person.properties : null,
config: {{
debug: true,
}}
}};

const initFn = {self._get_transpiled(edit_payload)}().init;

const processEvent = initFn({{ posthog, callback: console.log }}).processEvent;

processEvent(globals, posthog);;
"""

with STPyV8.JSContext() as ctxt:
ctxt.eval(js)
calls_json = ctxt.eval(
"JSON.stringify(calls)"
) # send a string type over the bridge as complex types can cause crashes
calls = json.loads(calls_json)
assert isinstance(calls, list)
return event_id, calls


# STPyV8 doesn't provide a window or document object, so set these up with a minimal implementation
JS_STDLIB = """
const document = {};
const window = {};

// easy but hacky impl, if we need a correct one, see
// https://github.com/zloirock/core-js/blob/4b7201bb18a66481d8aa7ca28782c151bc99f152/packages/core-js/modules/web.structured-clone.js#L109
const structuredClone = (obj) => JSON.parse(JSON.stringify(obj));
robbie-c marked this conversation as resolved.
Show resolved Hide resolved
window.structuredClone = structuredClone;
"""
241 changes: 241 additions & 0 deletions posthog/cdp/templates/reddit/template_reddit_pixel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
from posthog.cdp.templates.hog_function_template import HogFunctionMappingTemplate, HogFunctionTemplate

common_inputs = [
{
"key": "eventProperties",
"type": "dictionary",
"description": "Map of Reddit event attributes and their values. Check out these pages for more details: https://business.reddithelp.com/s/article/manual-conversion-events-with-the-reddit-pixel and https://business.reddithelp.com/s/article/about-event-metadata",
"label": "Event parameters",
"default": {
"conversion_id": "{event.uuid}",
"products": "{event.properties.products ? arrayMap(product -> ({'id': product.product_id, 'category': product.category, 'name': product.name}), event.properties.products) : event.properties.product_id ? [{'id': event.properties.product_id, 'category': event.properties.category, 'name': event.properties.name}] : undefined}",
"value": "{toFloat(event.properties.value ?? event.properties.revenue ?? event.properties.price)}",
"currency": "{event.properties.currency}",
},
"secret": False,
"required": False,
},
]

template_reddit_pixel: HogFunctionTemplate = HogFunctionTemplate(
free=True,
status="alpha",
type="site_destination",
id="template-reddit-pixel",
name="Reddit Pixel",
description="Track how many Reddit users interact with your website.",
icon_url="/static/services/reddit.png",
robbie-c marked this conversation as resolved.
Show resolved Hide resolved
category=["Advertisement"],
hog="""
// Adds window.rdt and lazily loads the Reddit Pixel script
function initSnippet() {
!function(w,d){if(!w.rdt){var p=w.rdt=function(){p.sendEvent?p.sendEvent.apply(p,arguments):p.callQueue.push(arguments)};p.callQueue=[];var t=d.createElement("script");t.src="https://www.redditstatic.com/ads/pixel.js",t.async=!0;var s=d.getElementsByTagName("script")[0];s.parentNode.insertBefore(t,s)}}(window,document);
}
robbie-c marked this conversation as resolved.
Show resolved Hide resolved

// These are the event names which we are allowed to call rdt with. If we want to send a different event name, we will
// need to use the 'Custom' event name, and pass original event name as 'customEventName' in event properties.
const RDT_ALLOWED_EVENT_NAMES = [
'PageVisit',
'Search',
'AddToCart',
'AddToWishlist',
'Purchase',
'ViewContent',
'Lead',
'SignUp',
'Custom',
];

export function onLoad({ inputs, posthog }) {
initSnippet();
let userProperties = {};
for (const [key, value] of Object.entries(inputs.userProperties)) {
if (value) {
userProperties[key] = value;
}
};
if (posthog.config.debug) {
console.log('[PostHog] rdt init', inputs.pixelId, userProperties);
}
rdt('init', inputs.pixelId, userProperties);
}
export function onEvent({ inputs, posthog }) {

let eventProperties = {};
for (const [key, value] of Object.entries(inputs.eventProperties)) {
if (value) {
eventProperties[key] = value;
}
};
let eventName;
if (RDT_ALLOWED_EVENT_NAMES.includes(inputs.eventType)) {
eventName = inputs.eventType;
} else {
eventName = 'Custom';
eventProperties.customEventName = inputs.eventType;
}
if (posthog.config.debug) {
console.log('[PostHog] rdt track', eventName, eventProperties);
}
rdt('track', eventName, eventProperties);
}
""".strip(),
inputs_schema=[
{
"key": "pixelId",
"type": "string",
"label": "Pixel ID",
"description": "You must obtain a Pixel ID to use the Reddit Pixel. If you've already set up a Pixel for your website, we recommend that you use the same Pixel ID for your browser and server events.",
"default": "",
"secret": False,
"required": True,
},
{
"key": "userProperties",
"type": "dictionary",
"description": "Map of Reddit user parameters and their values. Check out this page for more details: https://business.reddithelp.com/s/article/manual-conversion-events-with-the-reddit-pixel",
"label": "User parameters",
"default": {
"email": "{person.properties.email}",
},
"secret": False,
"required": False,
},
],
# See our event specification here:
# https://posthog.com/docs/data/event-spec/ecommerce-events
# And reddit's here:
# https://business.reddithelp.com/s/article/manual-conversion-events-with-the-reddit-pixel
mapping_templates=[
HogFunctionMappingTemplate(
name="Page Visit",
include_by_default=True,
filters={"events": [{"id": "$pageview", "name": "Pageview", "type": "events"}]},
inputs_schema=[
{
"key": "eventType",
"type": "string",
"label": "Event Type",
"description": "Check out this page for possible event types: https://business.reddithelp.com/s/article/manual-conversion-events-with-the-reddit-pixel",
"default": "PageVisit",
"required": True,
},
*common_inputs,
],
),
HogFunctionMappingTemplate(
name="Search",
include_by_default=True,
filters={"events": [{"id": "Products Searched", "name": "Products Searched", "type": "events"}]},
inputs_schema=[
{
"key": "eventType",
"type": "string",
"label": "Event Type",
"description": "Check out this page for possible event types: https://business.reddithelp.com/s/article/manual-conversion-events-with-the-reddit-pixel",
"default": "Search",
"required": True,
},
*common_inputs,
],
),
HogFunctionMappingTemplate(
name="Product Added",
include_by_default=True,
filters={"events": [{"id": "Product Added", "name": "Product Added", "type": "events"}]},
inputs_schema=[
{
"key": "eventType",
"type": "string",
"label": "Event Type",
"description": "Check out this page for possible event types: https://business.reddithelp.com/s/article/manual-conversion-events-with-the-reddit-pixel",
"default": "AddToCart",
"required": True,
},
*common_inputs,
],
),
HogFunctionMappingTemplate(
name="Product Added to Wishlist",
include_by_default=True,
filters={
"events": [{"id": "Product Added to Wishlist", "name": "Product Added to Wishlist", "type": "events"}]
},
inputs_schema=[
{
"key": "eventType",
"type": "string",
"label": "Event Type",
"description": "Check out this page for possible event types: https://business.reddithelp.com/s/article/manual-conversion-events-with-the-reddit-pixel",
"default": "AddToWishlist",
"required": True,
},
*common_inputs,
],
),
HogFunctionMappingTemplate(
name="Order Completed",
include_by_default=True,
filters={"events": [{"id": "Order Completed", "name": "Order Completed", "type": "events"}]},
inputs_schema=[
{
"key": "eventType",
"type": "string",
"label": "Event Type",
"description": "Check out this page for possible event types: https://business.reddithelp.com/s/article/manual-conversion-events-with-the-reddit-pixel",
"default": "Purchase",
"required": True,
},
*common_inputs,
],
),
HogFunctionMappingTemplate(
name="Product Viewed",
include_by_default=True,
filters={"events": [{"id": "Product Viewed", "name": "Product Viewed", "type": "events"}]},
inputs_schema=[
{
"key": "eventType",
"type": "string",
"label": "Event Type",
"description": "Check out this page for possible event types: https://business.reddithelp.com/s/article/manual-conversion-events-with-the-reddit-pixel",
"default": "ViewContent",
"required": True,
},
*common_inputs,
],
),
HogFunctionMappingTemplate(
name="Lead Generated",
include_by_default=True,
filters={"events": [{"id": "Lead Generated", "name": "Lead Generated", "type": "events"}]},
inputs_schema=[
{
"key": "eventType",
"type": "string",
"label": "Event Type",
"description": "Check out this page for possible event types: https://business.reddithelp.com/s/article/manual-conversion-events-with-the-reddit-pixel",
"default": "Lead",
"required": True,
},
*common_inputs,
],
),
HogFunctionMappingTemplate(
name="Signed Up",
include_by_default=True,
filters={"events": [{"id": "Signed Up", "name": "Signed Up", "type": "events"}]},
inputs_schema=[
{
"key": "eventType",
"type": "string",
"label": "Event Type",
"description": "Check out this page for possible event types: https://business.reddithelp.com/s/article/manual-conversion-events-with-the-reddit-pixel",
"default": "SignUp",
"required": True,
},
*common_inputs,
],
),
],
)
Loading
Loading