Skip to content

Instantly share code, notes, and snippets.

@VariableVic
Last active April 5, 2024 21:40
Show Gist options
  • Save VariableVic/dd3ff891cc68414c87ab54cd67e0bf8f to your computer and use it in GitHub Desktop.
Save VariableVic/dd3ff891cc68414c87ab54cd67e0bf8f to your computer and use it in GitHub Desktop.
Medusa -> Google Analytics 4 server side tracking example
import { TransactionBaseService } from "@medusajs/medusa";
import {
ConsentSettings,
EventItem,
JSONPostBody,
} from "../types/google-analytics";
const { subtle } = require("crypto").webcrypto;
// Set to true to enable debug mode. In debug mode, your events will not be processed in the Google Analytics UI, but will be validated as if they were.
const DEBUG = false;
class GoogleAnalyticsService extends TransactionBaseService {
protected measurementId_: string;
protected apiSecret_: string;
protected endpoint_: string;
protected debugPath_: string;
constructor() {
super(arguments);
this.measurementId_ = process.env.GA_MEASUREMENT_ID || "";
this.apiSecret_ = process.env.GA_API_SECRET || "";
this.debugPath_ = DEBUG ? "debug/mp" : "mp";
this.endpoint_ = `https://www.google-analytics.com/${this.debugPath_}/collect?measurement_id=${this.measurementId_}&api_secret=${this.apiSecret_}`;
}
async track({
clientId,
userId,
events,
userData,
consentSettings,
}: {
clientId: string;
userId?: string;
events: EventItem[];
userData?: Record<string, any>;
consentSettings?: ConsentSettings;
}): Promise<Response> {
const body: JSONPostBody = {
client_id: clientId,
user_id: userId,
events,
user_data: userData,
consent: consentSettings,
timestamp_micros: Date.now() * 1000,
};
const response = await fetch(this.endpoint_, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}).then((res) => (DEBUG ? res.json() : res));
DEBUG && console.log("Response", response);
return response;
}
async populateSensitiveUserData(value: string): Promise<string> {
const encoder = new TextEncoder();
// Convert a string value to UTF-8 encoded text.
const value_utf8 = encoder.encode(value);
// Compute the hash (digest) using the SHA-256 algorithm.
const hash_sha256 = await subtle.digest("SHA-256", value_utf8);
// Convert buffer to byte array.
const hash_array = Array.from(new Uint8Array(hash_sha256));
// Return a hex-encoded string.
return hash_array.map((b) => b.toString(16).padStart(2, "0")).join("");
}
}
export default GoogleAnalyticsService;
import {
OrderService,
type SubscriberConfig,
type SubscriberArgs,
} from "@medusajs/medusa";
import GoogleAnalyticsService from "../../services/google-analytics";
export default async function orderPlacedHandler({
data,
eventName,
container,
pluginOptions,
}: SubscriberArgs<Record<string, any>>) {
// Retrieve order service and GA service from the container
const orderService: OrderService = container.resolve("orderService");
const ga: GoogleAnalyticsService = container.resolve(
"googleAnalyticsService"
);
// Retrieve order with totals using the order id from the event payload
const order = await orderService.retrieveWithTotals(data.id, {
relations: [
"shipping_address",
"items",
"items.variant",
"items.variant.product.categories",
"customer",
],
});
// Create items array
const gaItems = order.items.map((item) => ({
item_id: item.variant_id,
item_name: item.title,
item_category: item.variant.product.categories?.[0]?.name,
price: item.unit_price / 100,
quantity: item.quantity,
}));
// Create events array
const events = [
{
name: "purchase",
params: {
transaction_id: order.id,
session_id: order.metadata.ga_session_id,
value: order.total / 100,
tax: order.tax_total / 100,
shipping: order.shipping_total / 100,
currency: order.currency_code,
coupon: order.discounts?.[0],
items: gaItems,
},
},
];
// Hash sensitive user data
const hashedEmail = await ga.populateSensitiveUserData(order.customer.email);
const hashedPhone = await ga.populateSensitiveUserData(order.customer.phone);
const hashedFirstName = await ga.populateSensitiveUserData(
order.customer.first_name
);
const hashedLastName = await ga.populateSensitiveUserData(
order.customer.last_name
);
const hashedStreet = await ga.populateSensitiveUserData(
order.shipping_address?.address_1
);
// Create user data object
const userData = {
sha256_email_address: [hashedEmail],
sha256_phone_number: [hashedPhone],
address: [
{
sha256_first_name: hashedFirstName,
sha256_last_name: hashedLastName,
sha256_street: hashedStreet,
city: order.shipping_address?.city,
region: order.shipping_address?.province,
postal_code: order.shipping_address?.postal_code,
country: order.shipping_address?.country_code,
},
],
};
// Send data to GA
await ga.track({
clientId: order.metadata.ga_client_id,
userId: order.customer_id,
userData,
events,
});
}
export const config: SubscriberConfig = {
event: OrderService.Events.PLACED,
context: {
subscriberId: "ga4-order-placed-handler",
},
};
import { MedusaRequest, MedusaResponse } from "@medusajs/medusa";
import GoogleAnalyticsService from "../../../../../services/google-analytics";
export async function POST(
req: MedusaRequest,
res: MedusaResponse
): Promise<void> {
const { user } = req;
const { name } = req.params;
let { params, consentSettings, clientId } = req.body;
const ga: GoogleAnalyticsService = req.scope.resolve(
"googleAnalyticsService"
);
clientId = clientId || "anonymous_" + new Date().getTime();
const userId = user?.customer_id || user?.id;
const events = [
{
name,
params,
},
];
const response = await ga.track({
clientId,
userId,
events,
consentSettings,
});
res.status(200).json({ response });
}
export type JSONPostBody = {
client_id: string; // Required. Uniquely identifies a user instance of a web client.
user_id?: string; // Optional. A unique identifier for a user.
timestamp_micros?: number; // Optional. A Unix timestamp (in microseconds) for the time to associate with the event.
user_data?: Record<string, any>; // Optional. The user data for the measurement.
consent?: ConsentSettings; // Optional. Sets the consent settings for events.
non_personalized_ads?: boolean; // Optional. Set to true to indicate these events should not be used for personalized ads.
events: EventItem[]; // Required. An array of event items. Up to 25 events can be sent per request.
};
export type ConsentSettings = {
ad_user_data?: "GRANTED" | "DENIED"; // Optional. The consent value for ad user data.
ad_personalization?: "GRANTED" | "DENIED"; // Optional. The consent value for ad personalization.
};
export type EventItem = {
name: string; // Required. The name for the event.
params?: Record<string, any>; // Optional. The parameters for the event.
};
@bqst
Copy link

bqst commented Mar 6, 2024

Thank you for this script! Quick question, where do you retrieve the order.metadata.ga_session_id from? I assume you add it from the front end at the checkout, I'm using your Next.js starter for Medusa.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment