Skip to content

Commit

Permalink
console,rotor: Braze destination
Browse files Browse the repository at this point in the history
  • Loading branch information
absorbb committed Sep 6, 2024
1 parent 1d40729 commit 2498e18
Show file tree
Hide file tree
Showing 5 changed files with 343 additions and 0 deletions.
278 changes: 278 additions & 0 deletions libs/core-functions/src/functions/braze-destination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import { FullContext, JitsuFunction } from "@jitsu/protocols/functions";
import { RetryError } from "@jitsu/functions-lib";
import type { AnalyticsServerEvent } from "@jitsu/protocols/analytics";
import { BrazeCredentials } from "../meta";
import { eventTimeSafeMs } from "./lib";
import omit from "lodash/omit";
import { pick } from "lodash";
import { identify } from "../../__tests__/lib/datalayout-test-data";

const endpoints = {
"US-01 : dashboard-01.braze.com": "https://rest.iad-01.braze.com",
"US-02 : dashboard-02.braze.com": "https://rest.iad-02.braze.com",
"US-03 : dashboard-03.braze.com": "https://rest.iad-03.braze.com",
"US-04 : dashboard-04.braze.com": "https://rest.iad-04.braze.com",
"US-05 : dashboard-05.braze.com": "https://rest.iad-05.braze.com",
"US-06 : dashboard-06.braze.com": "https://rest.iad-06.braze.com",
"US-07 : dashboard-07.braze.com": "https://rest.iad-07.braze.com",
"US-08 : dashboard-08.braze.com": "https://rest.iad-08.braze.com",
"US-09 : dashboard-09.braze.com": "https://rest.iad-09.braze.com",
"EU-01 : dashboard-01.braze.eu": "https://rest.fra-01.braze.eu",
"EU-02 : dashboard-02.braze.eu": "https://rest.fra-02.braze.eu",
};
export type HttpRequest = {
method?: string;
url: string;
payload?: any;
headers?: Record<string, string>;
};

function toBrazeGender(gender: string | null | undefined): string | null | undefined {
if (!gender) {
return gender;
}

const genders: Record<string, string[]> = {
M: ["man", "male", "m"],
F: ["woman", "female", "w", "f"],
O: ["other", "o"],
N: ["not applicable", "n"],
P: ["prefer not to say", "p"],
};

const brazeGender = Object.keys(genders).find(key => genders[key].includes(gender.toLowerCase()));
return brazeGender || gender;
}

function getAnonymousIdAlias(event: AnalyticsServerEvent, ctx: FullContext<BrazeCredentials>) {
if (ctx.props.useJitsuAnonymousIdAlias && event.anonymousId) {
return {
alias_name: event.anonymousId,
alias_label: "anonymous_id",
};
}
}

function getIdPart(event: AnalyticsServerEvent, ctx: FullContext<BrazeCredentials>) {
let idPart = {} as any;
const traits = event.traits || event.context?.traits || {};
const user_alias = traits.user_alias || event.properties?.user_alias || getAnonymousIdAlias(event, ctx);
const braze_id = traits.braze_id || event.properties?.braze_id;
if (event.userId) {
idPart.external_id = event.userId;
} else if (user_alias) {
idPart.user_alias = user_alias;
} else if (braze_id) {
idPart.braze_id = braze_id;
}
idPart.email = traits.email;
idPart.phone = traits.phone;
if (Object.keys(idPart).length === 0) {
throw new Error('One of "external_id", "user_alias", "braze_id", "email" or "phone" is required.');
}
return idPart;
}

function trackEvent(event: AnalyticsServerEvent, ctx: FullContext<BrazeCredentials>): any {
return {
events: [
{
...getIdPart(event, ctx),
app_id: ctx.props.appId,
name: event.event,
time: new Date(eventTimeSafeMs(event)).toISOString(),
properties: event.properties,
_update_existing_only: false,
},
],
};
}

function trackPurchase(event: AnalyticsServerEvent, ctx: FullContext<BrazeCredentials>): any {
const products = event.properties?.products as any[];
if (!products || !products.length) {
return;
}
const reservedKeys = ["product_id", "currency", "price", "quantity"];
const event_properties = omit(event.properties, ["products"]);
const base = {
...getIdPart(event, ctx),
app_id: ctx.props.appId,
time: new Date(eventTimeSafeMs(event)).toISOString(),
_update_existing_only: false,
};
return {
purchases: products.map(product => ({
...base,
product_id: product.product_id,
currency: product.currency ?? "USD",
price: product.price,
quantity: product.quantity,
properties: {
...omit(product, reservedKeys),
...event_properties,
},
})),
};
}

function updateUserProfile(event: AnalyticsServerEvent, ctx: FullContext<BrazeCredentials>): any {
const geo = ctx.geo || ({} as any);
const traits = event.traits || ({} as any);
const knownProps = [
"country",
"current_location",
"date_of_first_session",
"date_of_last_session",
"dob",
"email",
"email_subscribe",
"email_open_tracking_disabled",
"email_click_tracking_disabled",
"facebook",
"first_name",
"home_city",
"image_url",
"language",
"last_name",
"marked_email_as_spam_at",
"phone",
"push_subscribe",
"push_tokens",
"time_zone",
"twitter",
"subscription_groups",
];
return {
attributes: [
{
...getIdPart(event, ctx),
country: ctx.geo?.country?.name,
current_location:
geo.location?.latitude && geo.location?.longitude
? {
latitude: geo.location?.latitude,
longitude: geo.location?.longitude,
}
: undefined,
first_name: traits.firstName,
last_name: traits.lastName,
home_city: traits.address?.city,
image_url: traits.avatar,
time_zone: geo.location?.timezone,
gender: toBrazeGender(traits.gender),
...pick(traits, knownProps),
custom_attributes: omit(traits, knownProps),
_update_existing_only: false,
},
],
};
}

function identifyUser(event: AnalyticsServerEvent, ctx: FullContext<BrazeCredentials>): any {
const external_id = event.userId;
const user_alias = event.traits?.user_alias || getAnonymousIdAlias(event, ctx);
if (!external_id || !user_alias) {
return;
}
return {
aliases_to_identify: [
{
external_id,
user_alias,
},
],
merge_behavior: "merge",
};
}

const BrazeDestination: JitsuFunction<AnalyticsServerEvent, BrazeCredentials> = async (event, ctx) => {
const endpoint = endpoints[ctx.props.endpoint];
if (!endpoint) {
throw new Error(`Unknown endpoint ${ctx.props.endpoint}`);
}
try {
let httpRequests: HttpRequest[] = [];
const headers = {
"Content-type": "application/json",
Authorization: `Bearer ${ctx.props.apiKey}`,
};
const url = `${endpoint}/users/track`;
try {
if (event.type === "identify") {
httpRequests.push({
url,
payload: updateUserProfile(event, ctx),
headers,
});
const identify = identifyUser(event, ctx);
if (identify) {
httpRequests.push({
url: `${endpoint}/users/identify`,
payload: identify,
headers,
});
}
} else if (event.type === "track" && event.event != "Order Completed") {
httpRequests.push({
url,
payload: trackEvent(event, ctx),
headers,
});
} else if (event.type === "track" && event.event === "Order Completed") {
httpRequests.push({
url,
payload: trackPurchase(event, ctx),
headers,
});
} else if ((event.type === "page" || event.type === "screen") && ctx.props.sendPageEvents) {
const track = { ...event };
track.event = event.type;
const props = { ...event.properties };
if (event.name) {
props[`${event.type}_name`] = event.name;
}
if (event.category) {
props[`${event.type}_category`] = event.category;
}
track.properties = props;
httpRequests.push({
url,
payload: trackEvent(track, ctx),
headers,
});
}
} catch (e: any) {
ctx.log.error(e);
return false;
}

for (const httpRequest of httpRequests) {
if (httpRequest.payload) {
const method = httpRequest.method || "POST";
const result = await ctx.fetch(httpRequest.url, {
method,
headers: httpRequest.headers,
...(httpRequest.payload ? { body: JSON.stringify(httpRequest.payload) } : {}),
});
if (result.status !== 200 && result.status !== 201) {
throw new Error(
`Braze ${method} ${httpRequest.url}:${
httpRequest.payload ? `${JSON.stringify(httpRequest.payload)} --> ` : ""
}${result.status} ${await result.text()}`
);
} else {
ctx.log.debug(`Braze ${method} ${httpRequest.url}: ${result.status} ${await result.text()}`);
}
}
}
} catch (e: any) {
throw new RetryError(e.message);
}
};

BrazeDestination.displayName = "braze-destination";

BrazeDestination.description = "This functions covers jitsu events and sends them to Braze";

export default BrazeDestination;
2 changes: 2 additions & 0 deletions libs/core-functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import AmplitudeDestination from "./functions/amplitude-destination";
import FacebookConversionsApi from "./functions/facebook-conversions";
import IntercomDestination from "./functions/intercom-destination";
import HubspotDestination from "./functions/hubspot-destination";
import BrazeDestination from "./functions/braze-destination";

const builtinDestinations: Record<BuiltinDestinationFunctionName, JitsuFunction> = {
"builtin.destination.bulker": BulkerDestination as JitsuFunction,
"builtin.destination.mixpanel": MixpanelDestination as JitsuFunction,
"builtin.destination.intercom": IntercomDestination as JitsuFunction,
"builtin.destination.segment-proxy": SegmentDestination as JitsuFunction,
"builtin.destination.june": JuneDestination as JitsuFunction,
"builtin.destination.braze": BrazeDestination as JitsuFunction,
"builtin.destination.ga4": Ga4Destination as JitsuFunction,
"builtin.destination.webhook": WebhookDestination as JitsuFunction,
"builtin.destination.posthog": PosthogDestination as JitsuFunction,
Expand Down
42 changes: 42 additions & 0 deletions libs/core-functions/src/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,48 @@ export const JuneCredentials = z.object({
});
export type JuneCredentials = z.infer<typeof JuneCredentials>;

export const BrazeCredentials = z.object({
apiKey: z.string().describe(`API Key::Created under Developer Console in the Braze Dashboard.`),
endpoint: z
.enum([
"US-01 : dashboard-01.braze.com",
"US-02 : dashboard-02.braze.com",
"US-03 : dashboard-03.braze.com",
"US-04 : dashboard-04.braze.com",
"US-05 : dashboard-05.braze.com",
"US-06 : dashboard-06.braze.com",
"US-07 : dashboard-07.braze.com",
"US-08 : dashboard-08.braze.com",
"US-09 : dashboard-09.braze.com",
"EU-01 : dashboard-01.braze.eu",
"EU-02 : dashboard-02.braze.eu",
])
.optional()
.default("US-01 : dashboard-01.braze.com")
.describe(
"Your Braze REST endpoint. <a target='_blank' rel='noopener noreferrer' href='https://www.braze.com/docs/api/basics/#endpoints'>More details</a>"
),
appId: z
.string()
.optional()
.describe(
"App ID::The app identifier used to reference specific Apps in requests made to the Braze API. Created under Developer Console in the Braze Dashboard."
),
useJitsuAnonymousIdAlias: z
.boolean()
.optional()
.default(false)
.describe(
"Use Jitsu <code>anonymousId</code> as an alias for identified and anonymous profiles. Enables support for anonymous (alias-only) profiles."
),
sendPageEvents: z
.boolean()
.optional()
.default(false)
.describe("Send <code>page</code> and <code>screen</code> events as Braze Custom Events"),
});
export type BrazeCredentials = z.infer<typeof BrazeCredentials>;

export const SegmentCredentials = z.object({
apiBase: z.string().default("https://api.segment.io/v1").describe("API Base::Segment API Base"),
writeKey: z
Expand Down
10 changes: 10 additions & 0 deletions webapps/console/lib/schema/destinations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import hubspotIcon from "./icons/hubspot";
import mixpanelIcon from "./icons/mixpanel";
import facebookIcon from "./icons/facebook";
import juneIcon from "./icons/june";
import blazeIcon from "./icons/blaze";
import mongodbIcon from "./icons/mongodb";

import ga4Icon from "./icons/ga4";
Expand Down Expand Up @@ -694,6 +695,15 @@ export const coreDestinations: DestinationType<any>[] = [
credentials: meta.JuneCredentials,
description: "June.so is a product analytics platform that provides insights into user behavior.",
},
{
id: "braze",
icon: blazeIcon,
title: "Braze",
tags: "Product Analytics",
connectionOptions: CloudDestinationsConnectionOptions,
credentials: meta.BrazeCredentials,
description: "Braze is a customer engagement platform used by businesses for multichannel marketing.",
},
{
id: "mongodb",
icon: mongodbIcon,
Expand Down
11 changes: 11 additions & 0 deletions webapps/console/lib/schema/icons/blaze.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default (
<svg width="100%" height="100%" viewBox="0 0 256 256" version="1.1" preserveAspectRatio="xMidYMid">
<g>
<path
d="M115.665455,146.618182 C117.76,126.603636 130.327273,110.312727 144.989091,110.312727 C159.650909,110.312727 167.563636,126.603636 165.469091,146.618182 C163.374545,166.632727 151.272727,183.156364 137.541818,183.156364 C123.810909,183.156364 112.64,170.589091 115.665455,146.618182 M128,237.381818 C95.4203166,237.428273 64.5231317,222.918545 43.7527273,197.818182 C61.9054545,203.636364 82.1527273,198.516364 97.9781818,182.923636 C99.6494284,181.247143 101.204543,179.458761 102.632727,177.570909 C109.847273,193.163636 123.810909,201.076364 137.774545,201.076364 C161.512727,201.076364 181.527273,176.64 183.854545,146.385455 C186.181818,116.130909 169.192727,92.16 146.618182,92.16 C138.426818,92.046441 130.366511,94.2227238 123.345455,98.4436364 L130.327273,57.7163636 C131.023255,54.4563454 130.257956,51.0550185 128.232727,48.4072727 C126.138182,45.1490909 124.276364,43.7527273 121.483636,43.7527273 L88.9018182,43.7527273 C86.9755948,43.8531012 85.4024904,45.3278866 85.1781818,47.2436364 C84.3124445,51.0792109 83.6905928,54.9657841 83.3163636,58.88 C83.0836364,61.44 84.9454545,62.3709091 87.04,62.3709091 L110.778182,62.3709091 C107.054545,85.6436364 99.6072727,130.792727 97.5127273,142.196364 C95.3170885,152.990461 90.066367,162.926442 82.3854545,170.821818 C69.3527273,183.854545 45.3818182,186.88 31.1854545,172.683636 C26.2981818,167.563636 18.6181818,154.996364 18.6181818,128 C18.6181818,67.5900899 67.5900899,18.6181818 128,18.6181818 C188.40991,18.6181818 237.381818,67.5900899 237.381818,128 C237.381818,188.40991 188.40991,237.381818 128,237.381818 M128,0 C57.307552,-4.32866401e-15 8.65732801e-15,57.307552 0,128 C-8.65732801e-15,198.692448 57.307552,256 128,256 C198.692448,256 256,198.692448 256,128 C256,94.0522893 242.514324,61.4949884 218.509668,37.490332 C194.505012,13.4856756 161.947711,2.07869776e-15 128,0"
fill="#212124"
fill-rule="nonzero"
></path>
</g>
</svg>
);

0 comments on commit 2498e18

Please sign in to comment.