From 4321990cce9c81d91e1df97f17922f37ff48a6f8 Mon Sep 17 00:00:00 2001 From: Chris Moyer Date: Tue, 11 Aug 2026 09:20:12 -0400 Subject: [PATCH] feat(self-host): make vendor telemetry, endpoints, and support staff configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A self-hosted build currently ships Macro's own marketing stack and points itself at macro.com. None of it is gated on anything an operator can set. - analytics: move the GA4 / GTM / Google Ads / Meta Pixel ids out of the source and into VITE_* build env (new apps/web/src/lib/analytics/config.ts). Absent => the script is never injected, matching how PostHog is already gated. The only prior guard was `import.meta.env.DEV`, which is false in every real build — including the repo's own headless stack — so the sanctioned self-host bundle sent page views and user emails to Macro's GA property and Meta pixel. Macro's deploys inject the ids in CI. - servers: honor an explicit backend origin (VITE_BACKEND_ORIGIN, or the existing VITE_LOCAL_BACKEND_ORIGIN) in any build mode, not just MODE=development, and add VITE_AUTH_LOGOUT_URL. A production-mode bundle was otherwise hardwired to *.macro.com with no override path. - support channels: replace the hardcoded jacob@/julia@/teo@macro.com constants with SUPPORT_CHANNEL_HOST / SUPPORT_CHANNEL_MEMBERS. Unset => no support channel. User ids are email-derived, so a fork seeding those addresses grants whoever registers them membership in every user's private support channel. - macro_env: warn once when ENVIRONMENT is unset and new_or_prod() falls back to Production, which silently resolves every service URL to *.macro.com. Behavior for Macro's own dev/prod deploys is unchanged: the CI build passes the same ids, and the welcome-message test asserts byte-identical copy from the equivalent SUPPORT_CHANNEL_* config. --- .github/workflows/deploy_web_app.yml | 4 + apps/web/src/lib/analytics/analytics.ts | 72 +++++++---- apps/web/src/lib/analytics/config.ts | 52 ++++++++ .../src/lib/analytics/googleConversions.ts | 15 ++- apps/web/src/lib/analytics/providers.ts | 59 ++++++--- apps/web/src/lib/core/constant/servers.ts | 34 ++++- .../service-clients/service-stripe/client.ts | 5 +- apps/web/src/vite-env.d.ts | 13 ++ crates/macro_env/src/lib.rs | 23 +++- docs/RUNNING_LOCALLY.md | 38 ++++++ .../authentication_service/src/api/context.rs | 4 + .../api/webhooks/user/create_user_webhook.rs | 38 +++--- services/authentication_service/src/config.rs | 13 ++ services/authentication_service/src/main.rs | 18 +++ .../src/service/user/mod.rs | 1 + .../service/user/support_channel_welcome.rs | 46 ++++--- .../user/support_channel_welcome/test.rs | 60 ++++++++- .../src/service/user/support_team.rs | 116 ++++++++++++++++++ .../src/service/user/support_team/test.rs | 89 ++++++++++++++ .../src/workflows/deploy_web_app.rs | 11 ++ 20 files changed, 612 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/lib/analytics/config.ts create mode 100644 services/authentication_service/src/service/user/support_team.rs create mode 100644 services/authentication_service/src/service/user/support_team/test.rs diff --git a/.github/workflows/deploy_web_app.yml b/.github/workflows/deploy_web_app.yml index 19c99d57109..8bc740d3da9 100644 --- a/.github/workflows/deploy_web_app.yml +++ b/.github/workflows/deploy_web_app.yml @@ -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 diff --git a/apps/web/src/lib/analytics/analytics.ts b/apps/web/src/lib/analytics/analytics.ts index 5900575ec2f..ba7909aec4a 100644 --- a/apps/web/src/lib/analytics/analytics.ts +++ b/apps/web/src/lib/analytics/analytics.ts @@ -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, @@ -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 @@ -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'; @@ -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) { @@ -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) { @@ -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); @@ -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, diff --git a/apps/web/src/lib/analytics/config.ts b/apps/web/src/lib/analytics/config.ts new file mode 100644 index 00000000000..2498f1b4d1a --- /dev/null +++ b/apps/web/src/lib/analytics/config.ts @@ -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; diff --git a/apps/web/src/lib/analytics/googleConversions.ts b/apps/web/src/lib/analytics/googleConversions.ts index de445bf27af..e0893429333 100644 --- a/apps/web/src/lib/analytics/googleConversions.ts +++ b/apps/web/src/lib/analytics/googleConversions.ts @@ -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. @@ -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; diff --git a/apps/web/src/lib/analytics/providers.ts b/apps/web/src/lib/analytics/providers.ts index 193a928b63c..9cfa298daeb 100644 --- a/apps/web/src/lib/analytics/providers.ts +++ b/apps/web/src/lib/analytics/providers.ts @@ -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 = ` diff --git a/apps/web/src/lib/core/constant/servers.ts b/apps/web/src/lib/core/constant/servers.ts index 9c8fdcf3c56..be0c234ad5a 100644 --- a/apps/web/src/lib/core/constant/servers.ts +++ b/apps/web/src/lib/core/constant/servers.ts @@ -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`, @@ -53,8 +59,15 @@ type Servers = Record; // 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 @@ -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`, @@ -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; diff --git a/apps/web/src/lib/service-clients/service-stripe/client.ts b/apps/web/src/lib/service-clients/service-stripe/client.ts index 51778d6154d..aca28eaed9d 100644 --- a/apps/web/src/lib/service-clients/service-stripe/client.ts +++ b/apps/web/src/lib/service-clients/service-stripe/client.ts @@ -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'; @@ -18,14 +19,14 @@ function getMetaIds(): { fbp: string | undefined; fbc: string | undefined } { */ function getGaClientId(): Promise { 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); }); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index ec21d7d6426..e32ed3f0fef 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -6,6 +6,19 @@ interface ImportMetaEnv { readonly VITE_SEGMENT_WRITE_KEY: string; readonly VITE_POSTHOG_API_KEY: string; + // Third-party analytics ids. All optional: unset => that provider is never + // loaded. See src/lib/analytics/config.ts. + readonly VITE_GA_MEASUREMENT_ID?: string; + readonly VITE_GTM_CONTAINER_ID?: string; + readonly VITE_GOOGLE_ADS_ID?: string; + readonly VITE_META_PIXEL_ID?: string; + + // Backend origin overrides for self-hosted / single-origin deployments. + // See src/lib/core/constant/servers.ts. + readonly VITE_BACKEND_ORIGIN?: string; + readonly VITE_LOCAL_BACKEND_ORIGIN?: string; + readonly VITE_AUTH_LOGOUT_URL?: string; + readonly VITE_OTEL_EXPORTER_URL?: string; readonly VITE_OTEL_ENV?: string; readonly VITE_ENABLE_BROWSER_OTEL?: string; diff --git a/crates/macro_env/src/lib.rs b/crates/macro_env/src/lib.rs index 427e43f8a19..9087c021df5 100644 --- a/crates/macro_env/src/lib.rs +++ b/crates/macro_env/src/lib.rs @@ -48,8 +48,29 @@ impl Environment { } /// attempt to create a new [Environment] falling back to prod if we fail to construct + /// + /// The fallback is loud (once per process): `Production` makes every + /// [`macro_service_urls`](https://docs.rs/macro_service_urls) lookup resolve + /// to `*.macro.com`, so a deployment that simply forgot to set `ENVIRONMENT` + /// — a self-hosted instance, a one-off container — silently points itself at + /// Macro's infrastructure. Warn so it shows up in the first page of logs + /// instead of as puzzling cross-origin failures later. pub fn new_or_prod() -> Self { - Self::new_from_env().unwrap_or(Environment::Production) + match Self::new_from_env() { + Ok(environment) => environment, + Err(e) => { + static WARNED: std::sync::Once = std::sync::Once::new(); + WARNED.call_once(|| { + tracing::warn!( + error = ?e, + "ENVIRONMENT is unset or unrecognized; defaulting to `prod`, which \ + resolves service URLs to *.macro.com. Set ENVIRONMENT (prod/dev/local) \ + explicitly, and OVERRIDE_*_URL for a self-hosted deployment." + ); + }); + Environment::Production + } + } } /// Convert the environment variable into a doppler slug diff --git a/docs/RUNNING_LOCALLY.md b/docs/RUNNING_LOCALLY.md index ed02a9c4e05..97c997c6b1a 100644 --- a/docs/RUNNING_LOCALLY.md +++ b/docs/RUNNING_LOCALLY.md @@ -154,6 +154,44 @@ normal full init. `just stack snapshot` shows the current key; `--no-snapshot` opts out. This is also what makes Fly previews boot fast — CI bakes the snapshot into the preview image (see `infra/preview/README.md`). +## Self-Hosted Deployments + +A self-hosted deployment is the headless stack pointed at your own +infrastructure. Two classes of configuration are Macro-specific and default to +**off** rather than to Macro's own accounts, so an unconfigured deployment +neither reports to Macro nor seeds Macro staff into your users' data. + +**Third-party analytics** — none of it is compiled into the bundle. Each +provider loads only if this build was given its id, so an unset value means the +script is never injected and no request reaches the vendor +(`apps/web/src/lib/analytics/config.ts`): + +| Variable | Provider | +| ------------------------ | ---------------------------- | +| `VITE_GA_MEASUREMENT_ID` | Google Analytics 4 | +| `VITE_GTM_CONTAINER_ID` | Google Tag Manager | +| `VITE_GOOGLE_ADS_ID` | Google Ads conversions | +| `VITE_META_PIXEL_ID` | Meta Pixel | +| `VITE_POSTHOG_API_KEY` | PostHog (incl. session replay) | +| `VITE_OTEL_EXPORTER_URL` | Browser OpenTelemetry traces | + +**Backend endpoints** — a production-mode bundle used to be hardwired to +`*.macro.com`. Set `VITE_BACKEND_ORIGIN` to your single backend origin (the +Caddy proxy in front of the services, or `same-origin` to resolve it from +wherever the bundle is served) and every service call, websocket, and sync +connection follows it. `VITE_AUTH_LOGOUT_URL` points logout at your own +FusionAuth tenant; server side, set `ENVIRONMENT` explicitly and use +`OVERRIDE_*_URL` for each service (see `crates/macro_service_urls`) — leaving +`ENVIRONMENT` unset falls back to `prod`, which resolves to Macro's hosts and +logs a warning saying so. + +**Support channels** — new users get a private support channel seeded with the +accounts named by `SUPPORT_CHANNEL_HOST` (`email[:label]`, the account that +posts the welcome message) and `SUPPORT_CHANNEL_MEMBERS` (comma-separated +`email[:label]`). With `SUPPORT_CHANNEL_HOST` unset, no support channel is +created at all. Macro user ids are derived from the email address, so these must +be accounts on *your* instance. + ## Common Commands Run local binaries against shared dev resources instead of a fully local stack: diff --git a/services/authentication_service/src/api/context.rs b/services/authentication_service/src/api/context.rs index 4b99f6dc0c6..e514f8d61d3 100644 --- a/services/authentication_service/src/api/context.rs +++ b/services/authentication_service/src/api/context.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use analytics_client::AnalyticsClient; +use authentication_service::service::user::support_team::SupportTeam; use axum::extract::FromRef; use channels::{ domain::{ @@ -140,6 +141,9 @@ pub(crate) struct ApiContext { pub stripe_price_id: String, /// Whether Gmail link consent requests the Google Calendar scope. pub calendar_scope_enabled: bool, + /// The support team seeded into each new user's support channel, or `None` + /// when this deployment has none configured (support channels off). + pub support_team: Option>, } env_var! { diff --git a/services/authentication_service/src/api/webhooks/user/create_user_webhook.rs b/services/authentication_service/src/api/webhooks/user/create_user_webhook.rs index 83d61078c30..d835f3c322b 100644 --- a/services/authentication_service/src/api/webhooks/user/create_user_webhook.rs +++ b/services/authentication_service/src/api/webhooks/user/create_user_webhook.rs @@ -29,12 +29,8 @@ use macro_user_id::{ }; use model::authentication::webhooks::{FusionAuthUserWebhook, User as FusionAuthWebhookUser}; use model_entity::EntityType; -use std::collections::HashSet; use teams::domain::team_repo::TeamService; -/// Macro support team members added to every new user's support channel. -const MACRO_SUPPORT_EMAILS: [&str; 3] = ["jacob@macro.com", "julia@macro.com", "teo@macro.com"]; - fn support_channel_name>(email: &Email) -> String { format!("Macro Support x {}", email.local_part()) } @@ -355,20 +351,29 @@ async fn create_user_webhook(ctx: &ApiContext, req: FusionAuthUserWebhook) -> an // Seed the starter documents (the "Macro how to guide" and the starter // tasks it links to, with the guide pinned to the new user's sidebar // favorites), then create a private support channel connecting the new - // user with the Macro support team and post the welcome script — which + // user with the configured support team and post the welcome script — which // mentions the guide, so seeding runs first. Fire-and-forget: neither a // failed seeding (retried, then skipped) nor a failed channel creation // may block user creation. + // + // No support team configured (`SUPPORT_CHANNEL_HOST` unset — the default, + // and what a self-hosted deployment gets) => seed the docs and stop; there + // is nobody to put in the channel. tokio::spawn({ let document_storage_service_client = ctx.document_storage_service_client.clone(); let channel_service = ctx.channel_service.clone(); let favorites_service = ctx.favorites_service.clone(); + let support_team = ctx.support_team.clone(); let user_id = user_id.clone(); let email = email.clone(); let support_channel_name = support_channel_name.clone(); async move { initialize_starter_docs_with_retries(&document_storage_service_client, &user_id).await; + let Some(support_team) = support_team else { + return; + }; + let owner_id = match MacroUserIdStr::try_from(user_id) { Ok(owner_id) => owner_id, Err(e) => { @@ -377,17 +382,7 @@ async fn create_user_webhook(ctx: &ApiContext, req: FusionAuthUserWebhook) -> an } }; - let participants = match MACRO_SUPPORT_EMAILS - .into_iter() - .map(MacroUserIdStr::try_from_email) - .collect::, _>>() - { - Ok(participants) => participants, - Err(e) => { - tracing::error!(error=?e, "unable to parse support user ids for support channel"); - return; - } - }; + let participants = support_team.participants(); let channel = match channel_service .create_channel( @@ -418,9 +413,14 @@ async fn create_user_webhook(ctx: &ApiContext, req: FusionAuthUserWebhook) -> an tracing::error!(error=?e, channel_id=%channel.id, %email, "failed to favorite Macro support channel"); } - let _ = post_support_channel_welcome(channel_service.as_ref(), &channel.id, owner_id) - .await - .inspect_err(|e| { + let _ = post_support_channel_welcome( + channel_service.as_ref(), + &channel.id, + owner_id, + &support_team, + ) + .await + .inspect_err(|e| { tracing::error!(error=?e, channel_id=%channel.id, %email, "failed to post Macro support welcome message"); }); } diff --git a/services/authentication_service/src/config.rs b/services/authentication_service/src/config.rs index ebc6b149b81..43cad1a8bb7 100644 --- a/services/authentication_service/src/config.rs +++ b/services/authentication_service/src/config.rs @@ -44,6 +44,11 @@ maybe_env_vars! { pub struct PosthogApiKey; pub struct PosthogHost; pub struct LoopsApiKey; + /// `email[:label]` of the account that greets new users in their support + /// channel. Unset disables support channels entirely. + pub struct SupportChannelHost; + /// Comma-separated `email[:label]` entries added alongside the host. + pub struct SupportChannelMembers; } /// The configuration parameters for the application. @@ -118,6 +123,14 @@ pub struct Config { pub internal_api_key: InternalApiKey, /// Comma-separated Kafka bootstrap servers for the macro event broker. pub kafka_brokers: KafkaBrokers, + /// The account that greets a new user in their support channel, as + /// `email[:label]` (optional). Unset means new users get no support channel: + /// the accounts seeded into it are deployment-specific, and Macro's own + /// staff must not end up in a self-hosted instance's private channels. + pub support_channel_host: SupportChannelHost, + /// Comma-separated `email[:label]` entries added to each new user's support + /// channel alongside the host (optional). Requires `SUPPORT_CHANNEL_HOST`. + pub support_channel_members: SupportChannelMembers, /// Whether Gmail link consent requests the Google Calendar scope. Off by /// default so deployed environments don't ask users for a scope the /// calendar feature isn't using yet. diff --git a/services/authentication_service/src/main.rs b/services/authentication_service/src/main.rs index 38f688ea71d..305b938cbea 100644 --- a/services/authentication_service/src/main.rs +++ b/services/authentication_service/src/main.rs @@ -3,6 +3,7 @@ use analytics_client::{ AnalyticsClient, AnalyticsClientConfig, GoogleAnalyticsConfig, MetaConfig, PostHogConfig, }; use anyhow::{Context, anyhow}; +use authentication_service::service::user::support_team::SupportTeam; use channels::{ domain::{ service::ChannelServiceImpl, @@ -293,6 +294,22 @@ async fn main() -> anyhow::Result<()> { }; tracing::trace!("initialized loops client"); + // Resolve the support team up front so a bad SUPPORT_CHANNEL_* value fails + // startup instead of every signup. Unset (the default, and what a + // self-hosted deployment gets) => new users get no support channel. + let support_team = SupportTeam::from_config( + config.support_channel_host.value(), + config.support_channel_members.value(), + ) + .map_err(|e| anyhow!("invalid support channel configuration: {e:?}"))?; + match &support_team { + Some(team) => tracing::info!( + members = team.members.len() + 1, + "configuring new-user support channels" + ), + None => tracing::info!("SUPPORT_CHANNEL_HOST unset; new-user support channels disabled"), + } + let user_roles_and_permissions_macro_db = MacroDB::new(db.clone()); let user_roles_and_permissions_service = UserRolesAndPermissionsServiceImpl::new( @@ -411,6 +428,7 @@ async fn main() -> anyhow::Result<()> { environment: config.environment, rate_limit_service: rate_limit, calendar_scope_enabled: config.calendar_scope_enabled, + support_team: support_team.map(Arc::new), jwt_args, authorization_state, token_context: MacroApiTokenContext { diff --git a/services/authentication_service/src/service/user/mod.rs b/services/authentication_service/src/service/user/mod.rs index ca714dfe67b..9b96cbdf5ee 100644 --- a/services/authentication_service/src/service/user/mod.rs +++ b/services/authentication_service/src/service/user/mod.rs @@ -1,2 +1,3 @@ pub mod create_user; pub mod support_channel_welcome; +pub mod support_team; diff --git a/services/authentication_service/src/service/user/support_channel_welcome.rs b/services/authentication_service/src/service/user/support_channel_welcome.rs index 74716880577..f2361d30b49 100644 --- a/services/authentication_service/src/service/user/support_channel_welcome.rs +++ b/services/authentication_service/src/service/user/support_channel_welcome.rs @@ -9,13 +9,11 @@ use mention_utils::serialize::user_mention; use rootcause::{Report, prelude::ResultExt}; use uuid::Uuid; +use super::support_team::{SupportMember, SupportTeam}; + #[cfg(test)] mod test; -const JACOB_EMAIL: &str = "jacob@macro.com"; -const JULIA_EMAIL: &str = "julia@macro.com"; -const TEO_EMAIL: &str = "teo@macro.com"; - /// The channel operation required to post a new user's welcome messages. pub trait SupportChannelMessageGateway: Send + Sync + 'static { /// Post a welcome message. @@ -45,44 +43,56 @@ where } } -fn support_user(email: &str) -> Result, Report> { - Ok(MacroUserIdStr::try_from_email(email) - .context_with(|| format!("invalid Macro support user email: {email}"))?) +/// `me (julia)` for the host, ` (ceo)` for everyone else — the label is +/// omitted entirely when the deployment did not configure one. +fn labelled(rendered: String, member: &SupportMember) -> String { + match &member.label { + Some(label) => format!("{rendered} ({label})"), + None => rendered, + } } -/// Post Julia's welcome message in a newly created support channel. +/// Post the support host's welcome message in a newly created support channel. +/// +/// `team` is the configured [`SupportTeam`] (see that module for why it is +/// configuration rather than constants); its host is the sender, and its other +/// members are named in the copy. pub async fn post_support_channel_welcome( gateway: &impl SupportChannelMessageGateway, channel_id: &str, new_user: MacroUserIdStr<'static>, + team: &SupportTeam, ) -> Result<(), Report> { let channel_id = Uuid::parse_str(channel_id).context("support channel returned an invalid id")?; - let jacob = support_user(JACOB_EMAIL)?; - let julia = support_user(JULIA_EMAIL)?; - let teo = support_user(TEO_EMAIL)?; let new_user_mention = user_mention(&new_user)?; + // " (ceo) and (cto) and me (julia)" + let mut parties = Vec::with_capacity(team.members.len() + 1); + for member in &team.members { + parties.push(labelled(user_mention(&member.user_id)?, member)); + } + parties.push(labelled("me".to_string(), &team.host)); + let parties = parties.join(" and "); + let welcome = format!( "Hey {new_user_mention},\n\ \n\ Welcome to Macro, we're excited for you to try it out.\n\ \n\ -This is your own personal support Channel, with {} (ceo) and {} (cto) and me (julia).\n\ +This is your own personal support Channel, with {parties}.\n\ \n\ If you have any feedback or find any bugs let us know here.", - user_mention(&jacob)?, - user_mention(&teo)?, ); - // Keep Jacob and Teo visually mentioned without tracking them: tracked - // mentions would notify them on every signup. Julia is the sender, so the - // channel notification policy excludes her automatically. + // Keep the other support members visually mentioned without tracking them: + // tracked mentions would notify them on every signup. The host is the + // sender, so the channel notification policy excludes them automatically. let mentions = [&new_user].into_iter().map(SimpleMention::user).collect(); gateway .post_message( - Sender::new_from_user(julia), + Sender::new_from_user(team.host.user_id.clone()), channel_id, PostMessageRequest { content: welcome, diff --git a/services/authentication_service/src/service/user/support_channel_welcome/test.rs b/services/authentication_service/src/service/user/support_channel_welcome/test.rs index 126fee97c1b..ab0d4c5cc01 100644 --- a/services/authentication_service/src/service/user/support_channel_welcome/test.rs +++ b/services/authentication_service/src/service/user/support_channel_welcome/test.rs @@ -33,6 +33,18 @@ fn user_id(email: &str) -> MacroUserIdStr<'static> { MacroUserIdStr::try_from_email(email).unwrap() } +/// Macro's own support-team configuration, as set in Doppler. Asserting against +/// it keeps the deployed welcome copy byte-identical now that the team comes +/// from `SUPPORT_CHANNEL_HOST` / `SUPPORT_CHANNEL_MEMBERS` rather than consts. +fn macro_support_team() -> SupportTeam { + SupportTeam::from_config( + Some("julia@macro.com:julia"), + Some("jacob@macro.com:ceo,teo@macro.com:cto"), + ) + .unwrap() + .unwrap() +} + const NEW_USER_MENTION: &str = "{\"userId\":\"macro|new.user@example.com\",\"email\":\"new.user@example.com\"}"; fn expected_welcome() -> String { @@ -59,6 +71,7 @@ async fn posts_the_welcome_message() { &gateway, &channel_id.to_string(), user_id("new.user@example.com"), + ¯o_support_team(), ) .await .unwrap(); @@ -85,14 +98,53 @@ async fn posts_the_welcome_message() { assert_eq!(welcome.request.triggered_by, None); } +/// A self-hosted deployment that names one unlabelled host: no dangling +/// parentheses, no reference to anyone it did not configure. +#[tokio::test] +async fn posts_for_a_host_only_team_without_labels() { + let channel_id = Uuid::new_v4(); + let gateway = RecordingGateway::default(); + let team = SupportTeam::from_config(Some("admin@example.com"), None) + .unwrap() + .unwrap(); + + post_support_channel_welcome( + &gateway, + &channel_id.to_string(), + user_id("new.user@example.com"), + &team, + ) + .await + .unwrap(); + + let posted = gateway.posted.lock().unwrap(); + let [welcome] = posted.as_slice() else { + panic!("expected exactly one posted message, got {}", posted.len()); + }; + + assert_eq!(welcome.actor.as_user(), Some(&user_id("admin@example.com"))); + assert!( + welcome + .request + .content + .contains("This is your own personal support Channel, with me.\n"), + "unexpected copy: {}", + welcome.request.content + ); +} + #[tokio::test] async fn rejects_an_invalid_channel_id_without_posting() { let gateway = RecordingGateway::default(); - let error = - post_support_channel_welcome(&gateway, "not-a-uuid", user_id("new.user@example.com")) - .await - .unwrap_err(); + let error = post_support_channel_welcome( + &gateway, + "not-a-uuid", + user_id("new.user@example.com"), + ¯o_support_team(), + ) + .await + .unwrap_err(); assert_eq!( error.downcast_current_context::<&str>().copied(), diff --git a/services/authentication_service/src/service/user/support_team.rs b/services/authentication_service/src/service/user/support_team.rs new file mode 100644 index 00000000000..64c8fc1fc9d --- /dev/null +++ b/services/authentication_service/src/service/user/support_team.rs @@ -0,0 +1,116 @@ +//! The deployment's support team: the accounts added to every new user's +//! support channel, and the one that greets them there. +//! +//! Configured rather than compiled in. `SUPPORT_CHANNEL_HOST` and +//! `SUPPORT_CHANNEL_MEMBERS` are both optional, and unset — the default, and +//! what a self-hosted deployment gets — means no support channel is created at +//! all. That default matters: Macro user ids are derived from the email address +//! (`macro|`, see [`MacroUserIdStr::try_from_email`]), so seeding a +//! hardcoded `@macro.com` address into a fork's channels hands whoever controls +//! that address on that instance a membership in every user's private support +//! channel. +//! +//! Entry format is `email` or `email:label`, comma separated. The label is the +//! parenthetical shown in the welcome message (`… (ceo)`); it is display copy +//! only and never affects permissions. + +use std::collections::HashSet; + +use macro_user_id::user_id::MacroUserIdStr; +use rootcause::{Report, prelude::ResultExt as _}; + +#[cfg(test)] +mod test; + +/// A support-team configuration that cannot be resolved. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum SupportTeamConfigError { + /// Members were configured without anyone to greet the new user. + #[error( + "SUPPORT_CHANNEL_MEMBERS is set without SUPPORT_CHANNEL_HOST; the host posts the \ + welcome message, so it is required" + )] + MissingHost, +} + +/// One configured support-team account. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SupportMember { + /// The member's Macro user id, derived from the configured email. + pub user_id: MacroUserIdStr<'static>, + /// Parenthetical shown after the member's mention in the welcome message. + pub label: Option, +} + +impl SupportMember { + /// Parse a single `email` / `email:label` entry. + /// + /// Splits on the LAST colon so an entry stays parseable even though the + /// email itself may not contain one — a leading/trailing empty label is + /// treated as absent. + fn parse(entry: &str) -> Result { + let entry = entry.trim(); + let (email, label) = match entry.rsplit_once(':') { + Some((email, label)) => (email.trim(), Some(label.trim())), + None => (entry, None), + }; + + let user_id = MacroUserIdStr::try_from_email(email) + .context_with(|| format!("invalid support team email: {email}"))?; + + Ok(Self { + user_id, + label: label.filter(|l| !l.is_empty()).map(str::to_string), + }) + } +} + +/// The support team for this deployment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SupportTeam { + /// Posts the welcome message, and is a channel participant. + pub host: SupportMember, + /// Additional participants, mentioned (untracked) in the welcome message. + pub members: Vec, +} + +impl SupportTeam { + /// Resolve the configured team, or `None` when support channels are turned + /// off for this deployment. + /// + /// A host is what makes a team: the welcome message has to be posted by + /// someone. Members configured without a host is a misconfiguration rather + /// than a silent "off" — the caller surfaces it at startup. + pub fn from_config(host: Option<&str>, members: Option<&str>) -> Result, Report> { + let host = host.map(str::trim).filter(|h| !h.is_empty()); + let members = members.map(str::trim).filter(|m| !m.is_empty()); + + let Some(host) = host else { + if members.is_some() { + return Err(Report::new(SupportTeamConfigError::MissingHost).into()); + } + return Ok(None); + }; + + let host = SupportMember::parse(host)?; + let members = members + .map(|members| { + members + .split(',') + .filter(|entry| !entry.trim().is_empty()) + .map(SupportMember::parse) + .collect::, _>>() + }) + .transpose()? + .unwrap_or_default(); + + Ok(Some(Self { host, members })) + } + + /// Everyone who should be a participant of a new support channel. + pub fn participants(&self) -> HashSet> { + std::iter::once(self.host.user_id.clone()) + .chain(self.members.iter().map(|m| m.user_id.clone())) + .collect() + } +} diff --git a/services/authentication_service/src/service/user/support_team/test.rs b/services/authentication_service/src/service/user/support_team/test.rs new file mode 100644 index 00000000000..85d1a6ff30d --- /dev/null +++ b/services/authentication_service/src/service/user/support_team/test.rs @@ -0,0 +1,89 @@ +use super::*; + +fn user_id(email: &str) -> MacroUserIdStr<'static> { + MacroUserIdStr::try_from_email(email).unwrap() +} + +#[test] +fn unset_config_means_no_support_channel() { + assert_eq!(SupportTeam::from_config(None, None).unwrap(), None); + assert_eq!(SupportTeam::from_config(Some(""), None).unwrap(), None); + assert_eq!( + SupportTeam::from_config(Some(" "), Some(" ")).unwrap(), + None + ); +} + +#[test] +fn parses_a_host_and_labelled_members() { + let team = SupportTeam::from_config( + Some("julia@macro.com:julia"), + Some("jacob@macro.com:ceo, teo@macro.com:cto"), + ) + .unwrap() + .unwrap(); + + assert_eq!(team.host.user_id, user_id("julia@macro.com")); + assert_eq!(team.host.label.as_deref(), Some("julia")); + assert_eq!( + team.members + .iter() + .map(|m| (m.user_id.clone(), m.label.clone())) + .collect::>(), + vec![ + (user_id("jacob@macro.com"), Some("ceo".to_string())), + (user_id("teo@macro.com"), Some("cto".to_string())), + ] + ); + assert_eq!( + team.participants(), + [ + user_id("julia@macro.com"), + user_id("jacob@macro.com"), + user_id("teo@macro.com"), + ] + .into_iter() + .collect() + ); +} + +#[test] +fn labels_are_optional() { + let team = SupportTeam::from_config(Some("host@example.com"), Some("a@example.com,")) + .unwrap() + .unwrap(); + + assert_eq!(team.host.label, None); + assert_eq!(team.members.len(), 1); + assert_eq!(team.members[0].label, None); +} + +#[test] +fn a_host_alone_is_a_team() { + let team = SupportTeam::from_config(Some("host@example.com"), None) + .unwrap() + .unwrap(); + + assert!(team.members.is_empty()); + assert_eq!( + team.participants(), + [user_id("host@example.com")].into_iter().collect() + ); +} + +#[test] +fn members_without_a_host_is_a_misconfiguration() { + let error = SupportTeam::from_config(None, Some("jacob@macro.com:ceo")).unwrap_err(); + + assert_eq!( + error.downcast_current_context::(), + Some(&SupportTeamConfigError::MissingHost) + ); +} + +#[test] +fn rejects_an_invalid_email() { + let error = SupportTeam::from_config(Some("not-an-email"), None).unwrap_err(); + + assert!(format!("{error:?}").contains("invalid support team email")); +} diff --git a/tooling/xtask/crates/xtask_workflows/src/workflows/deploy_web_app.rs b/tooling/xtask/crates/xtask_workflows/src/workflows/deploy_web_app.rs index 6969bda417e..8d65709e273 100644 --- a/tooling/xtask/crates/xtask_workflows/src/workflows/deploy_web_app.rs +++ b/tooling/xtask/crates/xtask_workflows/src/workflows/deploy_web_app.rs @@ -118,12 +118,23 @@ fn checkout() -> Step { } /// Build identical across dev/prod up to `MODE` (`just build-`). +/// +/// The Google/Meta ids are injected here rather than compiled into the source: +/// they are Macro's own marketing properties, so a build that does not get them +/// (a self-hosted deployment, a fork's CI, `cargo x stack up`) loads no +/// third-party tracker at all. See `apps/web/src/lib/analytics/config.ts`. They +/// are public ids, not secrets — literals keep the wiring visible in the +/// generated workflow. fn build() -> Step { Step::new("Build") .run("just build-${{ inputs.environment }}") .working_directory(xtask_paths::repo_dir!("apps/web")) .add_env(Env::new("VITE_SEGMENT_WRITE_KEY", vars::SEGMENT_WRITE_KEY)) .add_env(Env::new("VITE_POSTHOG_API_KEY", vars::POSTHOG_API_KEY)) + .add_env(Env::new("VITE_GA_MEASUREMENT_ID", "G-52HPEL3FTV")) + .add_env(Env::new("VITE_GTM_CONTAINER_ID", "GTM-M58X7PJ8")) + .add_env(Env::new("VITE_GOOGLE_ADS_ID", "AW-11035820781")) + .add_env(Env::new("VITE_META_PIXEL_ID", "639142540393286")) .add_env(Env::new( "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' }}",