Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/workflows/deploy_web_app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ jobs:
env:
VITE_SEGMENT_WRITE_KEY: ${{ secrets.SEGMENT_WRITE_KEY }}
VITE_POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
VITE_GA_MEASUREMENT_ID: G-52HPEL3FTV
VITE_GTM_CONTAINER_ID: GTM-M58X7PJ8
VITE_GOOGLE_ADS_ID: AW-11035820781
VITE_META_PIXEL_ID: '639142540393286'
VITE_OTEL_EXPORTER_URL: ${{ inputs.environment == 'prod' && 'https://macro-prox-prod.macroverse.workers.dev/i/otlp/v1/traces' || 'https://macro-prox-dev.macroverse.workers.dev/i/otlp/v1/traces' }}
VITE_OTEL_ENV: ${{ inputs.environment == 'prod' && 'prod' || 'development' }}
working-directory: apps/web
Expand Down
72 changes: 47 additions & 25 deletions apps/web/src/lib/analytics/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { AppEventNames, AppEvents } from '@app/lib/analytics/app-events';
import {
GA_MEASUREMENT_ID,
GTAG_ENABLED,
META_PIXEL_ENABLED,
META_PIXEL_ID,
} from '@app/lib/analytics/config';
import {
type GoogleConversionAction,
googleConversionSendTo,
Expand Down Expand Up @@ -71,8 +77,6 @@ interface PageViewOptions {
location?: string;
}

const GA_ID = 'G-52HPEL3FTV';

// Meta Pixel distinguishes standard events (fbq('track', ...)) from custom
// events (fbq('trackCustom', ...)). Calling `track` with a non-standard name
// works but triggers Pixel Helper warnings and may affect Ads Manager
Expand Down Expand Up @@ -193,9 +197,11 @@ const createAnalytics = () => {
try {
match(provider)
.with('ga', () => {
if (!GTAG_ENABLED) return;
gtag('event', event, enriched);
})
.with('meta-pixel', () => {
if (!META_PIXEL_ENABLED) return;
const fbqMethod = META_STANDARD_EVENTS.has(event)
? 'track'
: 'trackCustom';
Expand Down Expand Up @@ -275,9 +281,12 @@ const createAnalytics = () => {
) => {
if (disabled) return;

const sendTo = googleConversionSendTo(action);
if (!sendTo) return;

try {
gtag('event', 'conversion', {
send_to: googleConversionSendTo(action),
send_to: sendTo,
...data,
});
} catch (e) {
Expand All @@ -289,16 +298,23 @@ const createAnalytics = () => {
if (disabled) return;

try {
gtag('config', GA_ID, {
user_id: userID,
...(info.email && { email: info.email }),
...(info.os && { os: info.os }),
});
// Both of these hand the user's email address to a third party (GA in
// cleartext, Meta hashed by fbevents.js), so they run only when this
// build was configured with that vendor's id.
if (GA_MEASUREMENT_ID) {
gtag('config', GA_MEASUREMENT_ID, {
user_id: userID,
...(info.email && { email: info.email }),
...(info.os && { os: info.os }),
});
}

fbq('init', '639142540393286', {
external_id: userID,
em: info.email,
});
if (META_PIXEL_ID) {
fbq('init', META_PIXEL_ID, {
external_id: userID,
em: info.email,
});
}

posthog.identify(userID, { ...info });
} catch (e) {
Expand All @@ -323,7 +339,9 @@ const createAnalytics = () => {
if (disabled) return;

try {
gtag('config', GA_ID, { user_id: undefined });
if (GA_MEASUREMENT_ID) {
gtag('config', GA_MEASUREMENT_ID, { user_id: undefined });
}

posthog.unregister(PLAN_TIER_AT_EVENT_PROPERTY);
posthog.unregister(HAS_PAID_ACCESS_AT_EVENT_PROPERTY);
Expand All @@ -342,19 +360,23 @@ const createAnalytics = () => {
const environment = getEnvironment();

try {
gtag('event', 'page_view', {
[DEVICE_PROPERTY]: deviceType,
[ENVIRONMENT_PROPERTY]: environment,
page_title: pageTitle,
page_location: pageLocation,
page_path: pagePath,
});
if (GTAG_ENABLED) {
gtag('event', 'page_view', {
[DEVICE_PROPERTY]: deviceType,
[ENVIRONMENT_PROPERTY]: environment,
page_title: pageTitle,
page_location: pageLocation,
page_path: pagePath,
});
}

fbq('track', 'PageView', {
[DEVICE_PROPERTY]: deviceType,
[ENVIRONMENT_PROPERTY]: environment,
content_name: pageTitle,
});
if (META_PIXEL_ENABLED) {
fbq('track', 'PageView', {
[DEVICE_PROPERTY]: deviceType,
[ENVIRONMENT_PROPERTY]: environment,
content_name: pageTitle,
});
}

posthog.capture('$pageview', {
[DEVICE_PROPERTY]: deviceType,
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/lib/analytics/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Build-time identifiers for the third-party analytics providers (Google
* Analytics, Google Tag Manager, Google Ads, Meta Pixel).
*
* Every id is **absent by default**. A build that does not set them — any
* self-hosted deployment, `cargo x stack up`, a fork's CI — loads no Google or
* Meta script and sends nothing to either vendor. Macro's own deploys inject
* the ids in CI; see `tooling/xtask/crates/xtask_workflows/src/workflows/deploy_web_app.rs`
* (the generator for `.github/workflows/deploy_web_app.yml`).
*
* This matches how PostHog is already gated: no `VITE_POSTHOG_API_KEY`, no
* PostHog. Do not reintroduce a hardcoded fallback — a self-hosted instance
* would then report its users' page views and email addresses into Macro's
* analytics properties.
*
* Read as literal `import.meta.env.VITE_*` member expressions on purpose: Vite
* substitutes those textually at build time, so a dynamic
* `import.meta.env[key]` lookup comes back `undefined` in a production bundle
* (same reason `VITE_ENABLE_REMINDERS` is read statically in `featureFlags.ts`).
*/

/** Treat unset, empty, and whitespace-only the same — CI passes `''` for an unconfigured secret. */
function configured(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}

/** GA4 measurement id, e.g. `G-XXXXXXXXXX`. */
export const GA_MEASUREMENT_ID = configured(
import.meta.env.VITE_GA_MEASUREMENT_ID
);

/** Google Tag Manager container id, e.g. `GTM-XXXXXXX`. */
export const GTM_CONTAINER_ID = configured(
import.meta.env.VITE_GTM_CONTAINER_ID
);

/** Google Ads account id (the `AW-...` prefix used by conversion `send_to`). */
export const GOOGLE_ADS_ID = configured(import.meta.env.VITE_GOOGLE_ADS_ID);

/** Meta Pixel id. */
export const META_PIXEL_ID = configured(import.meta.env.VITE_META_PIXEL_ID);

/**
* Whether `gtag` will exist at runtime. One `gtag.js` load serves both GA4 and
* Google Ads, so either id is enough to bootstrap it.
*/
export const GTAG_ENABLED =
GA_MEASUREMENT_ID !== undefined || GOOGLE_ADS_ID !== undefined;

/** Whether `fbq` will exist at runtime. */
export const META_PIXEL_ENABLED = META_PIXEL_ID !== undefined;
15 changes: 11 additions & 4 deletions apps/web/src/lib/analytics/googleConversions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
* shared with `leadValues.ts` for now; rebalance there.
*/

/** Google Ads account ID (the `AW-...` prefix). */
export const GOOGLE_ADS_ID = 'AW-11035820781';
import { GOOGLE_ADS_ID } from '@app/lib/analytics/config';

/**
* Per-action conversion labels from the Ads UI.
Expand All @@ -32,5 +31,13 @@ export const GOOGLE_CONVERSION_LABELS = {

export type GoogleConversionAction = keyof typeof GOOGLE_CONVERSION_LABELS;

export const googleConversionSendTo = (action: GoogleConversionAction) =>
`${GOOGLE_ADS_ID}/${GOOGLE_CONVERSION_LABELS[action]}`;
/**
* The `send_to` target for a conversion action, or `undefined` when this build
* has no `VITE_GOOGLE_ADS_ID` — a self-hosted deployment fires no conversions.
*/
export const googleConversionSendTo = (
action: GoogleConversionAction
): string | undefined =>
GOOGLE_ADS_ID
? `${GOOGLE_ADS_ID}/${GOOGLE_CONVERSION_LABELS[action]}`
: undefined;
59 changes: 39 additions & 20 deletions apps/web/src/lib/analytics/providers.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,60 @@
import { GOOGLE_ADS_ID } from '@app/lib/analytics/googleConversions';
import {
GA_MEASUREMENT_ID,
GOOGLE_ADS_ID,
GTM_CONTAINER_ID,
META_PIXEL_ID,
} from '@app/lib/analytics/config';

/**
* Load `gtag.js` and Google Tag Manager, for whichever of GA4 / Google Ads /
* GTM this build was given an id for. No ids configured (the self-hosted
* default) => nothing is injected and no request reaches googletagmanager.com.
*/
export const initializeGoogleAnalytics = () => {
const G_ID = 'G-52HPEL3FTV';
// One gtag.js load serves GA4 and Ads; bootstrap it with whichever id exists.
const bootstrapId = GA_MEASUREMENT_ID ?? GOOGLE_ADS_ID;

// Google Analytics
const gaScript = document.createElement('script');
gaScript.src = `https://www.googletagmanager.com/gtag/js?id=${G_ID}`;
gaScript.async = true;
document.head.appendChild(gaScript);
if (bootstrapId) {
const gaScript = document.createElement('script');
gaScript.src = `https://www.googletagmanager.com/gtag/js?id=${bootstrapId}`;
gaScript.async = true;
document.head.appendChild(gaScript);

// Registering the AW account on page load is what lets gtag pick up
// ?gclid=… from the URL into the _gcl_aw cookie, so subsequent
// gtag('event', 'conversion', ...) fires can be attributed to the ad click.
const gaInit = document.createElement('script');
gaInit.innerHTML = `
// Registering the AW account on page load is what lets gtag pick up
// ?gclid=… from the URL into the _gcl_aw cookie, so subsequent
// gtag('event', 'conversion', ...) fires can be attributed to the ad click.
const gaInit = document.createElement('script');
gaInit.innerHTML = `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${G_ID}', { send_page_view: false });
gtag('config', '${GOOGLE_ADS_ID}');
${GA_MEASUREMENT_ID ? `gtag('config', '${GA_MEASUREMENT_ID}', { send_page_view: false });` : ''}
${GOOGLE_ADS_ID ? `gtag('config', '${GOOGLE_ADS_ID}');` : ''}
`;
document.head.appendChild(gaInit);
document.head.appendChild(gaInit);
}

// Google Tag Manager
const gtmScript = document.createElement('script');
gtmScript.innerHTML = `
if (GTM_CONTAINER_ID) {
const gtmScript = document.createElement('script');
gtmScript.innerHTML = `
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-M58X7PJ8');
})(window,document,'script','dataLayer','${GTM_CONTAINER_ID}');
`;
document.head.appendChild(gtmScript);
document.head.appendChild(gtmScript);
}
};

/**
* Load the Meta Pixel. No `VITE_META_PIXEL_ID` (the self-hosted default) =>
* nothing is injected and no request reaches connect.facebook.net.
*/
export const initializeMetaPixel = () => {
const PIXEL_ID = '639142540393286';
const PIXEL_ID = META_PIXEL_ID;
if (!PIXEL_ID) return;

const fbqInit = document.createElement('script');
fbqInit.innerHTML = `
Expand Down
34 changes: 28 additions & 6 deletions apps/web/src/lib/core/constant/servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ const serverHostLocal: Servers = {

const devServerSuffix = import.meta.env.MODE === 'development' ? '-dev' : '';

// A self-hosted deployment runs its own FusionAuth tenant, so the logout URL
// (client id + tenant id included) has to be supplied rather than derived.
const authLogoutOverride: string | undefined = import.meta.env
.VITE_AUTH_LOGOUT_URL;

const authLogoutUrl =
import.meta.env.MODE === 'development'
authLogoutOverride ??
(import.meta.env.MODE === 'development'
? 'https://fusionauth-dev.macro.com/oauth2/logout?client_id=eb75fe7a-0ef1-4186-96d9-cc62cfb1d10c&tenantId=5e13f524-8d32-0454-81f8-061936256aa4'
: 'https://auth.macro.com/oauth2/logout?client_id=75409999-7dc4-4241-b73b-a51818c3a71c&tenantId=a3e53c3d-8d6a-3e92-d64c-fa3bf30a60be';
: 'https://auth.macro.com/oauth2/logout?client_id=75409999-7dc4-4241-b73b-a51818c3a71c&tenantId=a3e53c3d-8d6a-3e92-d64c-fa3bf30a60be');

const serverHostRemote = {
'auth-service': `https://auth-service${devServerSuffix}.macro.com`,
Expand Down Expand Up @@ -53,8 +59,15 @@ type Servers = Record<keyof typeof serverHostRemote, string>;
// the static bundle Caddy serves works unchanged on any host that reaches the
// proxy — localhost, a tunnel URL, a preview domain. (globalThis.location
// exists in both windows and workers.)
const rawLocalBackendOrigin: string | undefined = import.meta.env
.VITE_LOCAL_BACKEND_ORIGIN;
//
// VITE_BACKEND_ORIGIN is the same switch under a name that isn't a lie in a
// self-hosted build (where the backend is a deployed origin, not a local one);
// VITE_LOCAL_BACKEND_ORIGIN stays supported for the xtask stack and existing
// tooling. Setting it makes the app talk to that origin in ANY build mode — a
// production-mode bundle is otherwise hardwired to macro.com.
const rawLocalBackendOrigin: string | undefined =
import.meta.env.VITE_BACKEND_ORIGIN ??
import.meta.env.VITE_LOCAL_BACKEND_ORIGIN;
const proxyOrigin: string | undefined =
rawLocalBackendOrigin === 'same-origin'
? globalThis.location?.origin
Expand All @@ -76,16 +89,20 @@ function resolveProxyOrigin(configured: string | undefined) {
}
}

// Non-development builds still honor an explicit backend origin, so a
// self-hosted `bun run build` can be pointed at its own deployment instead of
// being pinned to macro.com. Unset (Macro's own builds) => serverHostRemote,
// unchanged.
export const SERVER_HOSTS: Servers =
import.meta.env.MODE === 'development'
? selectLocalServers()
: serverHostRemote;
: (proxyServers() ?? serverHostRemote);

function proxyServers(): Servers | undefined {
if (!proxyOrigin || !wsProxyOrigin) return undefined;
return {
'auth-service': `${proxyOrigin}/auth`,
'auth-logout': serverHostLocal['auth-logout'],
'auth-logout': authLogoutOverride ?? serverHostLocal['auth-logout'],
'pdf-service': serverHostLocal['pdf-service'], // no local container
'document-storage-service': `${proxyOrigin}/dss`,
'websocket-service': `${wsProxyOrigin}/websocket`,
Expand Down Expand Up @@ -166,6 +183,11 @@ function selectSyncServiceHost():
};
}
if (import.meta.env.MODE !== 'development') {
// Self-hosted production build with its own backend origin: sync lives
// behind the same single origin as every other service.
if (proxyOrigin && wsProxyOrigin) {
return { worker: `${proxyOrigin}/sync`, ws: `${wsProxyOrigin}/sync` };
}
return syncServiceHostRemote;
}
const selectedLocalServers: string = import.meta.env.VITE_LOCAL_SERVERS;
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/lib/service-clients/service-stripe/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GA_MEASUREMENT_ID } from '@app/lib/analytics/config';
import { registerClient } from '@core/util/mockClient';
import { authServiceClient } from '@service-auth/client';

Expand All @@ -18,14 +19,14 @@ function getMetaIds(): { fbp: string | undefined; fbc: string | undefined } {
*/
function getGaClientId(): Promise<string | undefined> {
return new Promise((resolve) => {
if (typeof gtag !== 'function') {
if (typeof gtag !== 'function' || !GA_MEASUREMENT_ID) {
resolve(undefined);
return;
}

const timeout = setTimeout(() => resolve(undefined), 500);

gtag('get', 'G-52HPEL3FTV', 'client_id', (clientId: string) => {
gtag('get', GA_MEASUREMENT_ID, 'client_id', (clientId: string) => {
clearTimeout(timeout);
resolve(clientId);
});
Expand Down
Loading
Loading