Implement GA4 service with Measurement Protocol calls FRE-5280
- Real GA4 Measurement Protocol implementation (page_view, purchase, waitlist_signup, conversion tracking) - Setup script with manual and automated (GCP Admin API) paths - GA4 env vars documented in .env.example Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -23,3 +23,7 @@ SENTRY_DSN=""
|
|||||||
SENTRY_ENVIRONMENT="development"
|
SENTRY_ENVIRONMENT="development"
|
||||||
SENTRY_RELEASE="0.1.0"
|
SENTRY_RELEASE="0.1.0"
|
||||||
SENTRY_TRACES_SAMPLE_RATE="0.1"
|
SENTRY_TRACES_SAMPLE_RATE="0.1"
|
||||||
|
|
||||||
|
# Google Analytics 4
|
||||||
|
GA4_MEASUREMENT_ID=""
|
||||||
|
GA4_API_SECRET=""
|
||||||
|
|||||||
@@ -1,66 +1,71 @@
|
|||||||
import { google } from 'googleapis';
|
import { analyticsEnv } from '../config/analytics.config';
|
||||||
import { analyticsEnv, EventType } from '../config/analytics.config';
|
|
||||||
|
const GA4_ENDPOINT = 'https://www.google-analytics.com/mp/collect';
|
||||||
|
|
||||||
// GA4 service
|
|
||||||
export class GA4Service {
|
export class GA4Service {
|
||||||
private auth: any;
|
private measurementId: string;
|
||||||
|
private apiSecret: string;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.auth = google.auth.fromAPIKey(analyticsEnv.GA4_API_SECRET || 'placeholder');
|
this.measurementId = analyticsEnv.GA4_MEASUREMENT_ID;
|
||||||
|
this.apiSecret = analyticsEnv.GA4_API_SECRET || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize GA4 client
|
|
||||||
*/
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
// TODO: Initialize GA4 client with measurement ID
|
if (!this.measurementId || this.measurementId === 'placeholder') {
|
||||||
console.log('GA4 client initialized');
|
console.warn('GA4: no measurement ID configured — events will be dropped');
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
/**
|
if (!this.apiSecret) {
|
||||||
* Send event to GA4
|
console.warn('GA4: no API secret configured — events will be dropped');
|
||||||
*/
|
return;
|
||||||
async sendEvent(
|
}
|
||||||
eventName: string,
|
this.initialized = true;
|
||||||
params: {
|
}
|
||||||
client_id: string;
|
|
||||||
[key: string]: any;
|
private async post(eventName: string, params: Record<string, unknown>): Promise<void> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
console.log('GA4 (dry):', eventName, params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${GA4_ENDPOINT}?measurement_id=${this.measurementId}&api_secret=${this.apiSecret}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: params.client_id || 'system',
|
||||||
|
events: [{ name: eventName, params }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok && res.status !== 204) {
|
||||||
|
console.error(`GA4 error: ${res.status} for event ${eventName}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GA4 send failed:', err);
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
|
||||||
// TODO: Implement GA4 event tracking
|
|
||||||
// const measurementId = analyticsEnv.GA4_MEASUREMENT_ID;
|
|
||||||
// await fetch(`https://www.google-analytics.com/mp/collect?measurement_id=${measurementId}&api_secret=${analyticsEnv.GA4_API_SECRET}`, {
|
|
||||||
// method: 'POST',
|
|
||||||
// body: JSON.stringify({
|
|
||||||
// events: [{ name: eventName, params }],
|
|
||||||
// }),
|
|
||||||
// });
|
|
||||||
|
|
||||||
console.log('GA4 event:', eventName, params);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Track page view
|
|
||||||
*/
|
|
||||||
async trackPageView(clientId: string, path: string, title?: string): Promise<void> {
|
async trackPageView(clientId: string, path: string, title?: string): Promise<void> {
|
||||||
await this.sendEvent('page_view', {
|
await this.post('page_view', {
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
page_path: path,
|
page_path: path,
|
||||||
page_title: title,
|
page_title: title || undefined,
|
||||||
|
engagement_time_msec: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Track e-commerce purchase
|
|
||||||
*/
|
|
||||||
async trackPurchase(
|
async trackPurchase(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
transactionId: string,
|
transactionId: string,
|
||||||
value: number,
|
value: number,
|
||||||
currency: string,
|
currency: string,
|
||||||
items: Array<{ name: string; price: number; quantity: number }>
|
items: Array<{ item_id: string; item_name: string; price: number; quantity: number }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.sendEvent('purchase', {
|
await this.post('purchase', {
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
transaction_id: transactionId,
|
transaction_id: transactionId,
|
||||||
value,
|
value,
|
||||||
@@ -69,36 +74,61 @@ export class GA4Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async trackWaitlistSignup(clientId: string, email?: string): Promise<void> {
|
||||||
* Track conversion
|
await this.post('waitlist_signup', {
|
||||||
*/
|
client_id: clientId,
|
||||||
|
email_hash: email ? await this.sha256(email) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async trackConversion(
|
async trackConversion(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
conversionName: string,
|
conversionName: string,
|
||||||
metadata?: Record<string, any>
|
metadata?: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.sendEvent('conversion', {
|
await this.post(conversionName, {
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
conversion_name: conversionName,
|
|
||||||
...metadata,
|
...metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get analytics data (for dashboards)
|
|
||||||
*/
|
|
||||||
async getMetrics(
|
async getMetrics(
|
||||||
dateRange: { startDate: string; endDate: string },
|
dateRange: { startDate: string; endDate: string },
|
||||||
metrics: string[],
|
metrics: string[],
|
||||||
dimensions?: string[]
|
dimensions?: string[],
|
||||||
): Promise<any> {
|
): Promise<{ rows: unknown[]; totals: unknown[] }> {
|
||||||
// TODO: Implement GA4 Analytics Data API
|
if (!this.measurementId || this.measurementId === 'placeholder') {
|
||||||
return {
|
return { rows: [], totals: [] };
|
||||||
rows: [],
|
}
|
||||||
totals: [],
|
|
||||||
};
|
const DataApi = await import('@google-analytics/data').catch(() => null);
|
||||||
|
if (!DataApi) {
|
||||||
|
console.warn('GA4: @google-analytics/data not installed — cannot query metrics');
|
||||||
|
return { rows: [], totals: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = new DataApi.BetaAnalyticsDataClient();
|
||||||
|
const [response] = await client.runReport({
|
||||||
|
property: `properties/${this.measurementId.replace('G-', '')}`,
|
||||||
|
dateRanges: [dateRange],
|
||||||
|
metrics: metrics.map(m => ({ name: m })),
|
||||||
|
dimensions: (dimensions || []).map(d => ({ name: d })),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
rows: response.rows || [],
|
||||||
|
totals: response.totals || [],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('GA4 query failed:', err);
|
||||||
|
return { rows: [], totals: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sha256(str: string): Promise<string> {
|
||||||
|
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str.toLowerCase().trim()));
|
||||||
|
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export instance
|
|
||||||
export const ga4Service = new GA4Service();
|
export const ga4Service = new GA4Service();
|
||||||
|
|||||||
176
scripts/setup-ga4.sh
Executable file
176
scripts/setup-ga4.sh
Executable file
@@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# GA4 Setup Script for ShieldAI
|
||||||
|
# Two modes:
|
||||||
|
# 1. MANUAL: Step-by-step guide for GA web console (no credentials needed)
|
||||||
|
# 2. AUTOMATED: Creates property + stream via Admin API (requires GCP service account)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/setup-ga4.sh # Print manual instructions
|
||||||
|
# ./scripts/setup-ga4.sh --auto # Automated setup (needs GOOGLE_APPLICATION_CREDENTIALS)
|
||||||
|
# ./scripts/setup-ga4.sh --env-only # Just print what to put in .env
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
show_manual_guide() {
|
||||||
|
cat <<'GUIDE'
|
||||||
|
╔══════════════════════════════════════════════════════════════╗
|
||||||
|
║ ShieldAI — Manual GA4 Setup Guide ║
|
||||||
|
║ ~5 minutes in Google Analytics web console ║
|
||||||
|
╚══════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
STEP 1 — Create GA4 Property
|
||||||
|
1. Go to https://analytics.google.com/
|
||||||
|
2. Admin → Create Property → "ShieldAI"
|
||||||
|
3. Set reporting time zone, currency
|
||||||
|
4. Click "Create"
|
||||||
|
|
||||||
|
STEP 2 — Configure Data Stream
|
||||||
|
1. In the new property: Admin → Data Streams → Add Stream → Web
|
||||||
|
2. Website URL: https://shieldai.com
|
||||||
|
3. Stream name: "ShieldAI Landing Page"
|
||||||
|
4. Click "Create stream"
|
||||||
|
5. Copy the Measurement ID (format: G-XXXXXXXXXX)
|
||||||
|
|
||||||
|
STEP 3 — Create API Secret
|
||||||
|
1. In the data stream details: Measurement Protocol API secrets → Create
|
||||||
|
2. Nickname: "ShieldAI Backend"
|
||||||
|
3. Copy the API Secret
|
||||||
|
|
||||||
|
STEP 4 — Set Up Conversion Events
|
||||||
|
1. In GA4: Admin → Conversions → New conversion event
|
||||||
|
2. Create: "waitlist_signup"
|
||||||
|
3. Create: "page_view" (auto-tracked by default)
|
||||||
|
4. Optionally: "conversion" (for tracked conversions)
|
||||||
|
|
||||||
|
STEP 5 — Configure Environment
|
||||||
|
Add to .env (or .env.prod for production):
|
||||||
|
GA4_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||||
|
GA4_API_SECRET=<api-secret-from-step-3>
|
||||||
|
|
||||||
|
STEP 6 — Verify
|
||||||
|
curl -X POST "https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=<secret>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"client_id":"test-001","events":[{"name":"page_view"}]}'
|
||||||
|
GUIDE
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_automated() {
|
||||||
|
if [ -z "${GOOGLE_APPLICATION_CREDENTIALS:-}" ]; then
|
||||||
|
echo "ERROR: GOOGLE_APPLICATION_CREDENTIALS not set"
|
||||||
|
echo "Set it to the path of your GCP service account JSON key"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v node &>/dev/null; then
|
||||||
|
echo "ERROR: node is required for automated setup"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "--- Automated GA4 Setup ---"
|
||||||
|
echo "Using service account: $GOOGLE_APPLICATION_CREDENTIALS"
|
||||||
|
|
||||||
|
# Generate a setup script that uses the Google Admin API
|
||||||
|
cat > /tmp/setup-ga4-auto.mjs << 'SCRIPT'
|
||||||
|
import { google } from 'googleapis';
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const creds = JSON.parse(readFileSync(process.env.GOOGLE_APPLICATION_CREDENTIALS, 'utf-8'));
|
||||||
|
const auth = new google.auth.GoogleAuth({
|
||||||
|
credentials: creds,
|
||||||
|
scopes: ['https://www.googleapis.com/auth/analytics.edit'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const analyticsAdmin = google.analyticsadmin({ version: 'v1beta', auth });
|
||||||
|
|
||||||
|
// Step 1: Create GA4 property
|
||||||
|
console.log('Creating GA4 property...');
|
||||||
|
const property = await analyticsAdmin.properties.create({
|
||||||
|
requestBody: {
|
||||||
|
displayName: 'ShieldAI',
|
||||||
|
industryCategory: 'TECHNOLOGY',
|
||||||
|
timeZone: 'America/New_York',
|
||||||
|
currencyCode: 'USD',
|
||||||
|
parent: `accounts/${creds.account_id || '103950747'}`, // Replace with actual account ID
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Property created: ${property.data.name}`);
|
||||||
|
|
||||||
|
// Step 2: Create web data stream
|
||||||
|
console.log('Creating web data stream...');
|
||||||
|
const stream = await analyticsAdmin.properties.dataStreams.create({
|
||||||
|
parent: property.data.name,
|
||||||
|
requestBody: {
|
||||||
|
type: 'WEB_DATA_STREAM',
|
||||||
|
displayName: 'ShieldAI Landing Page',
|
||||||
|
webStreamData: {
|
||||||
|
defaultUri: 'https://shieldai.com',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Data stream created: ${stream.data.name}`);
|
||||||
|
console.log(`Measurement ID: ${stream.data.webStreamData.measurementId}`);
|
||||||
|
|
||||||
|
// Step 3: Create conversion events
|
||||||
|
console.log('Creating conversion events...');
|
||||||
|
for (const event of ['waitlist_signup']) {
|
||||||
|
try {
|
||||||
|
await analyticsAdmin.properties.conversionEvents.create({
|
||||||
|
parent: property.data.name,
|
||||||
|
requestBody: { eventName: event },
|
||||||
|
});
|
||||||
|
console.log(`Conversion event created: ${event}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Conversion event ${event} may already exist: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output results
|
||||||
|
const output = {
|
||||||
|
propertyId: property.data.name.replace('properties/', ''),
|
||||||
|
measurementId: stream.data.webStreamData.measurementId,
|
||||||
|
streamId: stream.data.name,
|
||||||
|
streamName: stream.data.displayName,
|
||||||
|
};
|
||||||
|
writeFileSync('/tmp/ga4-setup-output.json', JSON.stringify(output, null, 2));
|
||||||
|
console.log('\nResults saved to /tmp/ga4-setup-output.json');
|
||||||
|
console.log(JSON.stringify(output, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
|
SCRIPT
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "To run the automated setup:"
|
||||||
|
echo " 1. Update the account_id in the script above"
|
||||||
|
echo " 2. cd $PROJECT_DIR && node /tmp/setup-ga4-auto.mjs"
|
||||||
|
echo ""
|
||||||
|
echo "NOTE: You need to provide the Google Analytics account ID."
|
||||||
|
echo "Find it at: https://analytics.google.com/ → Admin → Account Settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
show_env_only() {
|
||||||
|
cat <<'ENV'
|
||||||
|
Required .env additions for ShieldAI analytics:
|
||||||
|
|
||||||
|
GA4_MEASUREMENT_ID=G-XXXXXXXXXX # From GA4 data stream
|
||||||
|
GA4_API_SECRET= # From GA4 Measurement Protocol API secrets
|
||||||
|
MIXPANEL_TOKEN= # Mixpanel project token
|
||||||
|
MIXPANEL_API_SECRET= # Mixpanel project API secret
|
||||||
|
ENV
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
--auto)
|
||||||
|
setup_automated
|
||||||
|
;;
|
||||||
|
--env-only)
|
||||||
|
show_env_only
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
show_manual_guide
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Reference in New Issue
Block a user