diff --git a/apps/ai/src/runtime/graph-boundaries.test.ts b/apps/ai/src/runtime/graph-boundaries.test.ts index 04cb911e1..cc6b77f9a 100644 --- a/apps/ai/src/runtime/graph-boundaries.test.ts +++ b/apps/ai/src/runtime/graph-boundaries.test.ts @@ -44,6 +44,7 @@ describe("AI runtime graph boundaries", () => { for (const routeOnlyService of [ "DailySpendService", "CloudflareAnalyticsService", + "GoogleAnalyticsService", "AnomalyDetectionService", "AiTriageService", "DigestService", diff --git a/apps/alerting/src/scheduled.test.ts b/apps/alerting/src/scheduled.test.ts index 1c8029d80..71615972c 100644 --- a/apps/alerting/src/scheduled.test.ts +++ b/apps/alerting/src/scheduled.test.ts @@ -5,7 +5,7 @@ import { buildLayer, catchTickFailure, selectScheduledProgram, type ScheduledTic const cronCases = [ ["*/5 * * * *", ["anomaly", "cloudflareAnalytics", "planetScale"]], - ["*/15 * * * *", ["digest"]], + ["*/15 * * * *", ["digest", "googleAnalytics"]], ["0 * * * *", ["serviceMapRollup"]], ["* * * * *", ["alert", "error", "escalation", "fixVerification"]], ] as const @@ -27,6 +27,7 @@ describe("alerting Effect root", () => { error: tick("error"), escalation: tick("escalation"), fixVerification: tick("fixVerification"), + googleAnalytics: tick("googleAnalytics"), planetScale: tick("planetScale"), serviceMapRollup: tick("serviceMapRollup"), } satisfies ScheduledTickPrograms @@ -56,6 +57,7 @@ describe("alerting Effect root", () => { error: errorGate.await.pipe(Effect.andThen(record("error"))), escalation: record("escalation"), fixVerification: record("fixVerification"), + googleAnalytics: record("googleAnalytics"), planetScale: record("planetScale"), serviceMapRollup: record("serviceMapRollup"), } satisfies ScheduledTickPrograms @@ -81,6 +83,7 @@ describe("alerting Effect root", () => { error: tick("error"), escalation: tick("escalation"), fixVerification: tick("fixVerification"), + googleAnalytics: tick("googleAnalytics"), planetScale: tick("planetScale"), serviceMapRollup: tick("serviceMapRollup"), } satisfies ScheduledTickPrograms diff --git a/apps/alerting/src/scheduled.ts b/apps/alerting/src/scheduled.ts index b483fc1f1..1d5633ff1 100644 --- a/apps/alerting/src/scheduled.ts +++ b/apps/alerting/src/scheduled.ts @@ -14,6 +14,7 @@ import { Env } from "@maple/backend/platform/Env" import { ErrorsService } from "@maple/backend/services/errors/ErrorsService" import { EscalationService } from "@maple/backend/services/alerts/EscalationService" import { FixVerificationTickService } from "@maple/backend/services/errors/FixVerificationTickService" +import { GoogleAnalyticsService } from "@maple/backend/services/integrations/GoogleAnalyticsService" import { layerPg } from "@maple/backend/platform/DatabasePgLive" import { PullRequestLookupLive } from "@maple/backend/services/errors/pull-request-lookup-live" import { PlanetScaleService } from "@maple/backend/services/integrations/PlanetScaleService" @@ -37,6 +38,7 @@ export const buildLayer = (env: AlertingWorkerEnv) => AlertsService.layer, AnomalyDetectionService.layer, CloudflareAnalyticsService.layer, + GoogleAnalyticsService.layer, PlanetScaleService.layer, DigestService.layer, ErrorsService.layer, @@ -210,6 +212,21 @@ const cloudflareAnalyticsTick = makeTick( }), ) +/** + * Runs on the 15-minute cron, not Cloudflare's 5-minute one. GA4 does not update fast enough to + * reward a tighter cadence, and every tick spends Data API quota tokens per property. + */ +const googleAnalyticsTick = makeTick( + GoogleAnalyticsService.use((analytics) => analytics.pollAllOrgs()), + "google_analytics", + (result) => ({ + properties: result.properties, + rowsIngested: result.rowsIngested, + skipped: result.skipped, + failures: result.failures, + }), +) + const planetScaleTick = makeTick( PlanetScaleService.use((planetscale) => planetscale.pollAllOrgs()), "planetscale", @@ -233,6 +250,7 @@ export interface ScheduledTickPrograms { readonly error: Effect.Effect readonly escalation: Effect.Effect readonly fixVerification: Effect.Effect + readonly googleAnalytics: Effect.Effect readonly planetScale: Effect.Effect readonly serviceMapRollup: Effect.Effect } @@ -253,7 +271,9 @@ export const selectScheduledProgram = ( discard: true, }), ), - Match.when("*/15 * * * *", () => ticks.digest), + Match.when("*/15 * * * *", () => + Effect.all([ticks.digest, ticks.googleAnalytics], { concurrency: 2, discard: true }), + ), Match.when("0 * * * *", () => ticks.serviceMapRollup), Match.when("* * * * *", () => // `fixVerification` is chained onto `error` rather than listed beside it: @@ -283,6 +303,7 @@ type ScheduledServices = | ErrorsService | EscalationService | FixVerificationTickService + | GoogleAnalyticsService | PlanetScaleService | ServiceMapRollupService @@ -294,6 +315,7 @@ export const scheduledTicks: ScheduledTickPrograms = { error: errorTick, escalation: escalationTick, fixVerification: fixVerificationTick, + googleAnalytics: googleAnalyticsTick, planetScale: planetScaleTick, serviceMapRollup: serviceMapRollupTick, } diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index a3d2a20b4..6272a26aa 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -26,6 +26,7 @@ import { appUrlsEnv, authEnv, cloudflareOAuthEnv, + googleAnalyticsOAuthEnv, ingestKeyCryptoEnv, merge, optionalPlain, @@ -94,12 +95,13 @@ const configuredEnv = (stage: MapleStage) => optionalSecret("AUTUMN_SECRET_KEY"), optionalSecret("INTERNAL_SERVICE_TOKEN"), // The alerting worker is where incidents open and resolve, so it is the one - // that sends push (platform/Apns.ts) — and it runs the Cloudflare analytics - // and PlanetScale inventory pollers, each of which resolves and refreshes - // per-org OAuth tokens with the same config the api worker uses. + // that sends push (platform/Apns.ts) — and it runs the Cloudflare analytics, + // PlanetScale inventory and Google Analytics pollers, each of which resolves + // and refreshes per-org OAuth tokens with the same config the api worker uses. apnsEnv, cloudflareOAuthEnv, planetScaleOAuthEnv, + googleAnalyticsOAuthEnv, ) /** diff --git a/apps/api/src/resources/env.ts b/apps/api/src/resources/env.ts index 6414f1a3e..ff76e9a54 100644 --- a/apps/api/src/resources/env.ts +++ b/apps/api/src/resources/env.ts @@ -17,6 +17,7 @@ import { authEnv, cloudflareOAuthEnv, derived, + googleAnalyticsOAuthEnv, ingestKeyCryptoEnv, merge, optionalPlain, @@ -107,4 +108,5 @@ export const apiConfiguredEnv = (stage: MapleStage, domains: MapleDomains) => optionalPlain("GITHUB_API_BASE_URL"), cloudflareOAuthEnv, planetScaleOAuthEnv, + googleAnalyticsOAuthEnv, ) diff --git a/apps/api/src/routes/v1/integrations.http.ts b/apps/api/src/routes/v1/integrations.http.ts index 6d5ed7769..3ebd96265 100644 --- a/apps/api/src/routes/v1/integrations.http.ts +++ b/apps/api/src/routes/v1/integrations.http.ts @@ -54,6 +54,10 @@ import { } from "@maple/backend/services/integrations/cloudflare-analytics/queries" import { PlanetScaleConnectionService } from "@maple/backend/services/integrations/PlanetScaleConnectionService" import { PlanetScaleService } from "@maple/backend/services/integrations/PlanetScaleService" +import { + GOOGLE_ANALYTICS_CALLBACK_PATH, + GoogleAnalyticsOAuthService, +} from "@maple/backend/services/auth/GoogleAnalyticsOAuthService" import { PLANETSCALE_CALLBACK_PATH, PlanetScaleOAuthService, @@ -75,6 +79,7 @@ const HAZEL_MESSAGE_TYPE = "maple:integration:hazel" const GITHUB_MESSAGE_TYPE = "maple:integration:github" const CLOUDFLARE_MESSAGE_TYPE = "maple:integration:cloudflare" const PLANETSCALE_MESSAGE_TYPE = "maple:integration:planetscale" +const GOOGLE_ANALYTICS_MESSAGE_TYPE = "maple:integration:google-analytics" /** * How long `cloudflarePrime` spends on the post-connect poll. Long enough for zone discovery plus @@ -826,6 +831,7 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) => const cloudflareAnalytics = yield* CloudflareAnalyticsService const planetscaleOAuth = yield* PlanetScaleOAuthService const planetscaleConnection = yield* PlanetScaleConnectionService + const googleAnalyticsOAuth = yield* GoogleAnalyticsOAuthService const env = yield* Env const dashboardTargetOrigin = resolveDashboardTargetOrigin(env.MAPLE_APP_BASE_URL) @@ -850,6 +856,14 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) => messageType: CLOUDFLARE_MESSAGE_TYPE, label: "Cloudflare", }) + const googleAnalyticsCallbackPage = (params: Omit) => + renderCallbackPage({ + ...params, + targetOrigin: dashboardTargetOrigin, + messageType: GOOGLE_ANALYTICS_MESSAGE_TYPE, + label: "Google Analytics", + }) + const planetscaleCallbackPage = (params: Omit) => renderCallbackPage({ ...params, @@ -1271,5 +1285,83 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) => }) yield* router.add("GET", PLANETSCALE_CALLBACK_PATH, handlePlanetScale) + + const googleAnalyticsErrorPage = (message: string) => + htmlResponse(googleAnalyticsCallbackPage({ status: "error", message, returnTo: null }), 400) + + const handleGoogleAnalytics = Effect.fn("integrations.googleAnalyticsOAuthCallback")(function* ( + req: HttpServerRequest.HttpServerRequest, + ) { + const urlOption = Option.liftThrowable(() => new URL(req.url, "http://localhost"))() + if (Option.isNone(urlOption)) { + return googleAnalyticsErrorPage("Malformed callback URL") + } + const url = urlOption.value + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const oauthError = url.searchParams.get("error") + + if (oauthError) { + // Google's own codes are terse; `access_denied` is the one users actually hit, + // by closing the consent screen. + return googleAnalyticsErrorPage( + oauthError === "access_denied" + ? "Google sign-in was cancelled — the connection wasn't authorized." + : `Google returned an error (${oauthError})`, + ) + } + + if (!code || !state) { + return googleAnalyticsErrorPage("Missing code or state in callback") + } + + return yield* googleAnalyticsOAuth.completeConnect(code, state).pipe( + // The first collection is NOT run here. It takes tens of seconds on a grant with + // several properties, and the popup would sit blank for all of it; the dashboard + // calls `prime` from the tab that stays open instead. + // Collector state is deliberately preserved across a reconnect — the ledger is what + // stops the restatement window's hours being emitted twice. See the note above + // `GoogleAnalyticsService`. + // The callback page reduces failures to short human copy — make sure the real + // cause still lands in the server log for diagnosis. + Effect.tapError((error) => + Effect.logError("Google Analytics OAuth completeConnect failed", { + tag: error._tag, + message: error.message, + }), + ), + Effect.map((result) => + htmlResponse( + googleAnalyticsCallbackPage({ + status: "success", + message: + "Google Analytics connected. You can close this window and return to Maple.", + returnTo: result.returnTo, + }), + ), + ), + Effect.catchTags({ + // Validation/upstream messages are our own sanitized strings — and for this + // provider they carry the two refusals a user can actually act on: a grant + // with no refresh token, and one that reaches no GA4 property. + "@maple/http/errors/IntegrationsValidationError": (error) => + Effect.succeed(googleAnalyticsErrorPage(error.message)), + "@maple/http/errors/IntegrationsUpstreamError": (error) => + Effect.succeed(googleAnalyticsErrorPage(error.message)), + "@maple/http/errors/IntegrationsRevokedError": () => + Effect.succeed( + googleAnalyticsErrorPage( + "Google rejected the authorization — reconnect and try again", + ), + ), + "@maple/http/errors/IntegrationsPersistenceError": () => + Effect.succeed( + googleAnalyticsErrorPage("Failed to complete Google Analytics connection"), + ), + }), + ) + }) + + yield* router.add("GET", GOOGLE_ANALYTICS_CALLBACK_PATH, handleGoogleAnalytics) }), ) diff --git a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts index 0b59349ab..f93f3172f 100644 --- a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts +++ b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts @@ -56,6 +56,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, makeWarehouseServiceStub, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -190,6 +191,7 @@ const makeHarness = () => { Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), Layer.provideMerge(AuditLogService.layerMemory), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 989e99030..0f8072f3b 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -42,6 +42,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, makeWarehouseServiceStub, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -178,6 +179,7 @@ const makeHarness = ( Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), Layer.provideMerge(AuditLogService.layerMemory), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), diff --git a/apps/api/src/routes/v2/api-keys.http.test.ts b/apps/api/src/routes/v2/api-keys.http.test.ts index 79217f600..e3b9ff79a 100644 --- a/apps/api/src/routes/v2/api-keys.http.test.ts +++ b/apps/api/src/routes/v2/api-keys.http.test.ts @@ -19,6 +19,7 @@ import { AlertsServiceStubLayer, AllV2GroupLayersLive, ConfigResourceServiceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -64,6 +65,7 @@ const makeHarness = (checkRateLimit: RateLimiterApi["check"] = () => Effect.succ Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), diff --git a/apps/api/src/routes/v2/config-resources.http.test.ts b/apps/api/src/routes/v2/config-resources.http.test.ts index 055d2f1e6..140689ad3 100644 --- a/apps/api/src/routes/v2/config-resources.http.test.ts +++ b/apps/api/src/routes/v2/config-resources.http.test.ts @@ -27,6 +27,7 @@ import { ApiV2RateLimiterAllowAllLayer, makeWarehouseServiceStub, Phase1ResourceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, SetupAuditServiceStubLayer, @@ -114,6 +115,7 @@ const makeHarness = () => { Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SetupAuditServiceStubLayer), diff --git a/apps/api/src/routes/v2/dashboards.http.test.ts b/apps/api/src/routes/v2/dashboards.http.test.ts index fa3aba34c..5c6ea24fb 100644 --- a/apps/api/src/routes/v2/dashboards.http.test.ts +++ b/apps/api/src/routes/v2/dashboards.http.test.ts @@ -24,6 +24,7 @@ import { AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -68,6 +69,7 @@ const makeHarness = () => { Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), diff --git a/apps/api/src/routes/v2/integrations.http.test.ts b/apps/api/src/routes/v2/integrations.http.test.ts index 248c92700..3417fc74e 100644 --- a/apps/api/src/routes/v2/integrations.http.test.ts +++ b/apps/api/src/routes/v2/integrations.http.test.ts @@ -49,6 +49,7 @@ import { AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, + GoogleAnalyticsServiceStubsLayer, TelemetryServiceStubsLayer, } from "./v2-test-support" @@ -170,6 +171,7 @@ const makeHarness = (slack: Partial = {}, planetscal Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(slackServiceLayer(slack)), Layer.provide(planetscaleServiceLayer(planetscale)), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), diff --git a/apps/api/src/routes/v2/integrations.http.ts b/apps/api/src/routes/v2/integrations.http.ts index 8beefdb32..f866928fd 100644 --- a/apps/api/src/routes/v2/integrations.http.ts +++ b/apps/api/src/routes/v2/integrations.http.ts @@ -8,6 +8,10 @@ import type { import { CurrentTenant } from "@maple/domain/http" import type { PlanetScaleDatabaseRow } from "@maple/db" import type { + V2GoogleAnalyticsConnectResponse, + V2GoogleAnalyticsDisconnectResponse, + V2GoogleAnalyticsIntegration, + V2GoogleAnalyticsPrimeResponse, V2PlanetScaleConnectResponse, V2PlanetScaleDatabase, V2PlanetScaleDatabaseList, @@ -35,6 +39,12 @@ import { recordHttpAudit } from "@maple/backend/services/audit/AuditLogService" import { requireAdmin } from "@maple/backend/services/auth/auth" import { Env } from "@maple/backend/platform/Env" import { EdgeCacheService } from "@maple/cache" +import { + GOOGLE_ANALYTICS_CALLBACK_PATH, + GoogleAnalyticsOAuthService, +} from "@maple/backend/services/auth/GoogleAnalyticsOAuthService" +import type { GoogleAnalyticsIntegrationStatus } from "@maple/backend/services/integrations/GoogleAnalyticsService" +import { GoogleAnalyticsService } from "@maple/backend/services/integrations/GoogleAnalyticsService" import { PLANETSCALE_CALLBACK_PATH, PlanetScaleOAuthService, @@ -50,6 +60,12 @@ import { SlackIntegrationService, } from "@maple/backend/services/integrations/SlackIntegrationService" +/** + * How long `prime` spends on the post-connect poll. Long enough for property discovery plus a + * first window on an ordinary grant; a many-propertied one resumes on the next cron tick. + */ +const GOOGLE_ANALYTICS_PRIME_TIMEOUT = "20 seconds" + /** * Best-effort origin of the incoming request. `x-forwarded-*` is client-supplied * on any path that does not strip it, so the result is NOT trusted on its own — @@ -596,3 +612,152 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( ) }), ) + +/** + * Google Analytics 4. Reads are open to any org member; every mutation is admin-gated, matching + * the other integrations. The connect flow is the same trusted-origin dance as PlanetScale: the + * callback URL is persisted and replayed as `redirect_uri` at token exchange, and the origin is + * derived from a client-settable header, so an untrusted one would mint an authorize URL pointing + * at a host the caller controls. + */ +export const HttpV2GoogleAnalyticsIntegrationsLive = HttpApiBuilder.group( + MapleApiV2, + "googleAnalyticsIntegration", + (handlers) => + Effect.gen(function* () { + const analytics = yield* GoogleAnalyticsService + const googleOAuth = yield* GoogleAnalyticsOAuthService + const env = yield* Env + + const toStatus = (status: GoogleAnalyticsIntegrationStatus): V2GoogleAnalyticsIntegration => ({ + object: "google_analytics_integration" as const, + connected: status.connected, + connected_at: isoTimestampOrNull(status.connectedAt), + connected_email: status.externalUserEmail, + revoked: status.revoked, + properties: status.properties.map((property) => ({ + object: "google_analytics_property" as const, + property_id: property.propertyId, + property_name: property.propertyName, + account_name: property.accountName, + time_zone: property.timeZone, + enabled: property.enabled, + last_synced_at: isoTimestampOrNull(property.lastSyncedAt), + last_error: property.lastError, + watermark_at: isoTimestampOrNull(property.watermarkAt), + backfill_at: isoTimestampOrNull(property.backfillAt), + })), + }) + + return handlers + .handle("status", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const status = yield* analytics + .getIntegrationStatus(tenant.orgId) + .pipe(tapHttpErrors("Google Analytics status failed")) + return toStatus(status) + }), + ) + .handle("connect", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, () => + V2InsufficientPermissions.make("Only org admins can connect Google Analytics"), + ) + const req = yield* HttpServerRequest.HttpServerRequest + const origin = resolveRequestOrigin(req) + if (!isTrustedCallbackOrigin(origin, env.MAPLE_APP_BASE_URL)) { + yield* Effect.logError( + "Rejected Google Analytics connect: untrusted callback origin", + { origin }, + ) + return yield* Effect.fail( + V2CallbackHostUnavailable.make( + "Google Analytics connections are not available from this host", + ), + ) + } + const result = yield* googleOAuth + .startConnect(tenant.orgId, tenant.userId, { + callbackUrl: `${origin}${GOOGLE_ANALYTICS_CALLBACK_PATH}`, + returnTo: payload.return_to, + }) + .pipe(tapHttpErrors("Google Analytics connect failed")) + yield* recordHttpAudit("google_analytics_integration.connect_started") + return { + object: "google_analytics_integration.connect" as const, + redirect_url: result.redirectUrl, + state: result.state, + } satisfies V2GoogleAnalyticsConnectResponse + }), + ) + .handle("disconnect", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, () => + V2InsufficientPermissions.make("Only org admins can disconnect Google Analytics"), + ) + const result = yield* googleOAuth + .disconnect(tenant.orgId) + .pipe(tapHttpErrors("Google Analytics disconnect failed")) + // Collector state is deliberately NOT cleared here — see the note above + // `GoogleAnalyticsService`. Metrics already collected are retained, so the + // ledger has to outlive the grant or a reconnect inside the restatement + // window re-emits those hours on top of rows already in the warehouse. + yield* recordHttpAudit("google_analytics_integration.disconnected") + return { + object: "google_analytics_integration.disconnect" as const, + disconnected: result.disconnected, + } satisfies V2GoogleAnalyticsDisconnectResponse + }), + ) + .handle("prime", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, () => + V2InsufficientPermissions.make("Only org admins can run a Google Analytics sync"), + ) + // Bounded: discovery plus a first window on an ordinary account fits well + // inside this, and whatever a many-propertied grant does not finish simply + // resumes on the next cron tick. + const result = yield* analytics + .pollOrg(tenant.orgId) + .pipe(Effect.timeoutOption(GOOGLE_ANALYTICS_PRIME_TIMEOUT)) + return { + object: "google_analytics_integration.prime" as const, + properties: Option.match(result, { + onNone: () => 0, + onSome: (value) => value.properties, + }), + rows_ingested: Option.match(result, { + onNone: () => 0, + onSome: (value) => value.rowsIngested, + }), + } satisfies V2GoogleAnalyticsPrimeResponse + }), + ) + .handle("updateProperty", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, () => + V2InsufficientPermissions.make( + "Only org admins can change Google Analytics collection", + ), + ) + yield* analytics + .setPropertyEnabled(tenant.orgId, params.property_id, payload.enabled) + .pipe(tapHttpErrors("Google Analytics property update failed")) + yield* recordHttpAudit( + payload.enabled + ? "google_analytics_integration.property_enabled" + : "google_analytics_integration.property_disabled", + ) + const status = yield* analytics + .getIntegrationStatus(tenant.orgId) + .pipe(tapHttpErrors("Google Analytics status failed")) + return toStatus(status) + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/mobile-devices.http.test.ts b/apps/api/src/routes/v2/mobile-devices.http.test.ts index 5f7e5dea2..65427274b 100644 --- a/apps/api/src/routes/v2/mobile-devices.http.test.ts +++ b/apps/api/src/routes/v2/mobile-devices.http.test.ts @@ -21,6 +21,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, Phase1ResourceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -78,6 +79,7 @@ const makeHarness = () => { Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), Layer.provideMerge(AuditLogService.layerMemory), diff --git a/apps/api/src/routes/v2/onboarding-checklist.http.test.ts b/apps/api/src/routes/v2/onboarding-checklist.http.test.ts index 9e27842fd..4baf38af9 100644 --- a/apps/api/src/routes/v2/onboarding-checklist.http.test.ts +++ b/apps/api/src/routes/v2/onboarding-checklist.http.test.ts @@ -26,6 +26,7 @@ import { AlertsServiceStubLayer, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, + GoogleAnalyticsServiceStubsLayer, makeWarehouseServiceStub, Phase1ResourceStubsLayer, PlanetScaleServiceStubsLayer, @@ -122,6 +123,7 @@ const makeHarness = (status: OnboardingChecklistEvaluation["status"]) => { Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provide(warehouseLive), Layer.provideMerge(ApiAuthorizationV2Layer), diff --git a/apps/api/src/routes/v2/phase1-resources.http.test.ts b/apps/api/src/routes/v2/phase1-resources.http.test.ts index 719218065..65a01440b 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -66,6 +66,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, makeWarehouseServiceStub, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -554,6 +555,7 @@ const makeHarness = ( Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), diff --git a/apps/api/src/routes/v2/setup-audit.http.test.ts b/apps/api/src/routes/v2/setup-audit.http.test.ts index 30230c6d2..cab2f9362 100644 --- a/apps/api/src/routes/v2/setup-audit.http.test.ts +++ b/apps/api/src/routes/v2/setup-audit.http.test.ts @@ -30,6 +30,7 @@ import { ApiV2RateLimiterAllowAllLayer, makeWarehouseServiceStub, Phase1ResourceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -146,6 +147,7 @@ const makeHarness = (warehouse: WarehouseQueryServiceApi = warehouseStub()) => { Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provide(warehouseLive), Layer.provideMerge(ApiAuthorizationV2Layer), diff --git a/apps/api/src/routes/v2/telemetry-signals.http.test.ts b/apps/api/src/routes/v2/telemetry-signals.http.test.ts index b45b197bd..4589a1924 100644 --- a/apps/api/src/routes/v2/telemetry-signals.http.test.ts +++ b/apps/api/src/routes/v2/telemetry-signals.http.test.ts @@ -25,6 +25,7 @@ import { SignalPresenceService } from "@maple/backend/services/org/SignalPresenc import { V2TransportErrorBoundaryLive } from "./error-envelope" import { AlertsServiceStubLayer, + GoogleAnalyticsServiceStubsLayer, AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, makeWarehouseServiceStub, @@ -129,6 +130,7 @@ const makeHarness = (warehouse: WarehouseQueryServiceApi) => { Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provide(warehouseLive), Layer.provideMerge(ApiAuthorizationV2Layer), diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index 7da2f05b4..11587e3c3 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -30,6 +30,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, makeWarehouseServiceStub, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, } from "./v2-test-support" @@ -276,6 +277,7 @@ const makeHarness = ( Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index b217048d3..07e011e10 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -19,6 +19,8 @@ import { OrgIngestKeysService } from "@maple/backend/services/org/OrgIngestKeysS import { RecommendationIssueService } from "@maple/backend/services/errors/RecommendationIssueService" import { PlanetScaleConnectionService } from "@maple/backend/services/integrations/PlanetScaleConnectionService" import { PlanetScaleOAuthService } from "@maple/backend/services/auth/PlanetScaleOAuthService" +import { GoogleAnalyticsOAuthService } from "@maple/backend/services/auth/GoogleAnalyticsOAuthService" +import { GoogleAnalyticsService } from "@maple/backend/services/integrations/GoogleAnalyticsService" import { PlanetScaleService } from "@maple/backend/services/integrations/PlanetScaleService" import { ScrapeTargetsService } from "@maple/backend/services/integrations/ScrapeTargetsService" import { SlackIntegrationService } from "@maple/backend/services/integrations/SlackIntegrationService" @@ -36,7 +38,11 @@ import { HttpV2ApiKeysLive } from "./api-keys.http" import { HttpV2AttributeMappingsLive } from "./attribute-mappings.http" import { HttpV2DashboardsLive } from "./dashboards.http" import { HttpV2IngestKeysLive } from "./ingest-keys.http" -import { HttpV2PlanetScaleIntegrationsLive, HttpV2SlackIntegrationsLive } from "./integrations.http" +import { + HttpV2GoogleAnalyticsIntegrationsLive, + HttpV2PlanetScaleIntegrationsLive, + HttpV2SlackIntegrationsLive, +} from "./integrations.http" import { HttpV2ErrorIssuesLive } from "./error-issues.http" import { HttpV2AnomaliesLive } from "./anomalies.http" import { HttpV2InvestigationsLive } from "./investigations.http" @@ -95,6 +101,7 @@ export const V2GroupLayersExceptOnboardingChecklist = Layer.mergeAll( HttpV2ApiKeysLive, HttpV2SlackIntegrationsLive, HttpV2PlanetScaleIntegrationsLive, + HttpV2GoogleAnalyticsIntegrationsLive, HttpV2DashboardsLive, HttpV2AlertDeliveriesLive, HttpV2AlertRulesLive, @@ -331,6 +338,26 @@ export const PlanetScaleServiceStubsLayer = Layer.mergeAll( EdgeCacheService.layer.pipe(Layer.provide(MemoryCacheBackendLive)), ) +/** + * Inert Google Analytics services for harnesses that never touch that integration group. + */ +export const GoogleAnalyticsServiceStubsLayer = Layer.mergeAll( + Layer.succeed(GoogleAnalyticsService, { + pollAllOrgs: die, + pollOrg: die, + getIntegrationStatus: die, + setPropertyEnabled: die, + }), + Layer.succeed(GoogleAnalyticsOAuthService, { + startConnect: die, + completeConnect: die, + getStatus: die, + getValidAccessToken: die, + disconnect: die, + markConnectionRevoked: die, + }), +) + /** Inert SlackIntegrationService for harnesses that never touch the slack integration group. */ export const SlackIntegrationServiceStubLayer = Layer.succeed( SlackIntegrationService, diff --git a/apps/api/src/routes/v2/widget-credentials.http.test.ts b/apps/api/src/routes/v2/widget-credentials.http.test.ts index 66ef3daa6..b52636f66 100644 --- a/apps/api/src/routes/v2/widget-credentials.http.test.ts +++ b/apps/api/src/routes/v2/widget-credentials.http.test.ts @@ -21,6 +21,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, Phase1ResourceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -76,6 +77,7 @@ const makeHarness = () => { Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), Layer.provideMerge(AuditLogService.layerMemory), diff --git a/apps/api/src/routes/v2/widget-summary.http.test.ts b/apps/api/src/routes/v2/widget-summary.http.test.ts index 358782692..d9a87d198 100644 --- a/apps/api/src/routes/v2/widget-summary.http.test.ts +++ b/apps/api/src/routes/v2/widget-summary.http.test.ts @@ -35,6 +35,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, makeWarehouseServiceStub, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, } from "./v2-test-support" @@ -191,6 +192,7 @@ const makeHarness = (options: { Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 90275c770..11b368a37 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -41,7 +41,11 @@ import { HttpV2DashboardsLive } from "@/routes/v2/dashboards.http" import { V2TransportErrorBoundaryLive } from "@/routes/v2/error-envelope" import { HttpV2ErrorIssuesLive } from "@/routes/v2/error-issues.http" import { HttpV2IngestKeysLive } from "@/routes/v2/ingest-keys.http" -import { HttpV2PlanetScaleIntegrationsLive, HttpV2SlackIntegrationsLive } from "@/routes/v2/integrations.http" +import { + HttpV2GoogleAnalyticsIntegrationsLive, + HttpV2PlanetScaleIntegrationsLive, + HttpV2SlackIntegrationsLive, +} from "@/routes/v2/integrations.http" import { HttpV2InvestigationsLive } from "@/routes/v2/investigations.http" import { HttpV2MobileDevicesLive } from "@/routes/v2/mobile-devices.http" import { HttpV2OrganizationLive } from "@/routes/v2/organization.http" @@ -133,6 +137,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( HttpV2IngestKeysLive, HttpV2SlackIntegrationsLive, HttpV2PlanetScaleIntegrationsLive, + HttpV2GoogleAnalyticsIntegrationsLive, HttpV2ErrorIssuesLive, HttpV2AttributeMappingsLive, HttpV2AuditLogLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index c11c868bc..e05988a80 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -13,6 +13,7 @@ import { PlanetScaleOAuthService } from "@maple/backend/services/auth/PlanetScal import { AuthService } from "@maple/backend/services/auth/AuthService" import { CliDeviceAuthService } from "@maple/backend/services/auth/CliDeviceAuthService" import { CloudflareOAuthService } from "@maple/backend/services/auth/CloudflareOAuthService" +import { GoogleAnalyticsOAuthService } from "@maple/backend/services/auth/GoogleAnalyticsOAuthService" import { HazelOAuthService } from "@maple/backend/services/auth/HazelOAuthService" import { McpOAuthService } from "@maple/backend/services/auth/McpOAuthService" @@ -34,6 +35,7 @@ import { ErrorsService } from "@maple/backend/services/errors/ErrorsService" import { InvestigationService } from "@maple/backend/services/errors/InvestigationService" import { RecommendationIssueService } from "@maple/backend/services/errors/RecommendationIssueService" import { CloudflareAnalyticsService } from "@maple/backend/services/integrations/CloudflareAnalyticsService" +import { GoogleAnalyticsService } from "@maple/backend/services/integrations/GoogleAnalyticsService" import { PlanetScaleConnectionService } from "@maple/backend/services/integrations/PlanetScaleConnectionService" import { PlanetScaleDiscoveryService } from "@maple/backend/services/integrations/PlanetScaleDiscoveryService" import { PlanetScaleService } from "@maple/backend/services/integrations/PlanetScaleService" @@ -78,6 +80,7 @@ export const HttpServicesLive = Layer.mergeAll( CliDeviceAuthService.layer, McpOAuthService.layer, CloudflareOAuthService.layer, + GoogleAnalyticsOAuthService.layer, DashboardPersistenceService.layer, SharedDashboardService.layer, HazelOAuthService.layer, @@ -99,6 +102,7 @@ export const HttpServicesLive = Layer.mergeAll( ProductEventsService.layer, DailySpendService.layer, CloudflareAnalyticsService.layer, + GoogleAnalyticsService.layer, AuditLogService.layer, WarehouseQueryService.layer, QueryEngineService.layer, diff --git a/apps/clickhouse-builder-docs/src/sidebar-icons.tsx b/apps/clickhouse-builder-docs/src/sidebar-icons.tsx index 46b50695d..63dbb2228 100644 --- a/apps/clickhouse-builder-docs/src/sidebar-icons.tsx +++ b/apps/clickhouse-builder-docs/src/sidebar-icons.tsx @@ -301,8 +301,11 @@ const icons = { type SidebarIconName = keyof typeof icons -// `in` would accept `__proto__`, `constructor` and every other inherited name, -// and hand the SVG a function to render. +/** + * Sidebar names come from doc frontmatter, so an unknown one is expected, not a bug — and since + * the name is arbitrary text, `in` is the wrong test: it would accept `__proto__`, `constructor` + * and every other inherited name, and hand the SVG a function to render. + */ function isSidebarIconName(name: string): name is SidebarIconName { return Object.hasOwn(icons, name) } diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index 19dd41138..635db637d 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -7347,6 +7347,10 @@ "description": "Connect PlanetScale to your organization and manage what Maple collects from it: connection status, organization binding, the metrics service token that enables branch-metrics scraping, the database inventory, webhook setup, query insights, and the lifecycle event timeline.", "name": "PlanetScale Integration" }, + { + "description": "Connect Google Analytics 4 to your organization and manage what Maple collects from it: connection status, the properties discovered under the grant, and which of them are collected. Collected data lands as regular metrics, so it charts and alerts alongside your traces.", + "name": "Google Analytics Integration" + }, { "description": "Deduplicated errors and alert-backed issues tracked through Maple's triage workflow.", "name": "Error Issues" diff --git a/apps/web/perf/check-bundle-budget.ts b/apps/web/perf/check-bundle-budget.ts index 888164eba..e08f61cf6 100644 --- a/apps/web/perf/check-bundle-budget.ts +++ b/apps/web/perf/check-bundle-budget.ts @@ -65,6 +65,17 @@ const gzipBytes = chunks.reduce((total, chunk) => total + chunk.gzipBytes, 0) // the role/intent id literals and legacy-save maps in the quick-start atom the // root gate reads, and one lab registry entry. The cards' own copy is split // off so the route chunk carries it. main was at 683.4 KB. +// 687 KB from the Google Analytics integration (2026-09-09): 1.6 KB of startup, +// all of it the v2 domain contract every page's API client carries. Measured by +// registering and unregistering that one group against the same build — 686.2 +// with `V2GoogleAnalyticsIntegrationsApiGroup` on `MapleApiV2`, 684.6 without — +// so the card, the catalog entry, the icon and the template-icon entry cost +// nothing measurable between them. It is the same category as the Releases and +// AI-detect contracts above, just larger: five endpoints and six schemas rather +// than one. The weight is the OpenAPI descriptions, and those are the public API +// documentation — cutting them to buy back a kilobyte of startup is the wrong +// trade. Splitting the group out of the client is not available either: every +// page's client is built from the whole `MapleApiV2` surface. // 689 KB from #832 (2026-09-11): the agent Tools pages cost ~6.9 KB of startup // measured against main's 681.3 — five internal endpoints on the contract every // page's client carries, two route registrations whose search schemas pull in @@ -77,6 +88,21 @@ const gzipBytes = chunks.reduce((total, chunk) => total + chunk.gzipBytes, 0) // for the new reads, and the detail route's `variant` search param. The display // rules, the redaction list they read and the modal's parts stay in the route // chunk; none of them is in the startup graph. +// 691 KB on merging the two above (2026-09-11): they stack, so neither branch's +// own ceiling covers the pair. Measured on the merged tree by the same +// register/unregister of `V2GoogleAnalyticsIntegrationsApiGroup` — 690.7 with it, +// 689.1 without — so the GA contract still costs the 1.6 KB it did in isolation. +// Note the 689.1: the baseline had already reached #832's ceiling before the GA +// group was added back, so the headroom under 689 was gone independently of this +// branch. 691 leaves ~0.3 KB, which is thin; the next startup addition of any +// size will need its own raise and its own measurement. +// 693 KB on merging #865 with the above (2026-09-12): the same stacking again — +// #865 measured 690 against a baseline without the GA contract, so neither +// ceiling covers the pair. Merged measures 692.1, and the same +// register/unregister puts the GA group at 690.5 without it: 1.6 KB, the third +// time that number has held. The pattern is now the point — a startup addition +// merged alongside another one needs its ceiling re-measured on the merge, not +// taken as the larger of the two. // 692 KB from product events as a query-builder source (#877, 2026-09-13): // ~1.9 KB of startup measured against main's 689.9 — the fourth source's // aggregation, group-by and where-clause vocabularies in the query-builder @@ -90,13 +116,20 @@ const gzipBytes = chunks.reduce((total, chunk) => total + chunk.gzipBytes, 0) // blocks on the stored and v2 schemas, two picker presets with their icon, // and the paths widget type's lowering. Both charts, the paths query panel // and the sample data stay in lazy chunks. +// 695 KB on merging #877 and #878 with the GA integration (2026-09-13): the +// stacking once more. Merged measures 694.6 locally; unregistering +// `V2GoogleAnalyticsIntegrationsApiGroup` puts it at 693.0, exactly main's +// ceiling, so the GA contract is still the same 1.6 KB. 695 leaves ~0.4 KB. // 695 KB from the onboarding reward checklist (#890, 2026-09-14): ~1.2 KB of // startup measured in CI against #878's 693.0 — the checklist group's two // endpoints and step/status schemas on the contract every page's client // carries, the pill in the persistent top bar with its live clock, and the // hook's query and seen-flag atoms. The popover's panel and pointer are small // and render only while open, so nothing there is worth a lazy chunk. -const maxGzipBytes = 695 * 1024 +// 697 KB on merging #890 with the GA integration (2026-09-14): both raised to +// 695 alone. Merged measures 696.6; without `V2GoogleAnalyticsIntegrationsApiGroup` +// it is 695.0, main's ceiling exactly, so the GA contract is 1.6 KB a fifth time. +const maxGzipBytes = 697 * 1024 const budgetLabel = `${(maxGzipBytes / 1024).toFixed(1)} KB` // Anything lazy-only: chat, replay, and every dev-only lab surface. The diff --git a/apps/web/src/components/dashboard-builder/templates/template-icons.ts b/apps/web/src/components/dashboard-builder/templates/template-icons.ts index 6cd11a7a0..97e138c36 100644 --- a/apps/web/src/components/dashboard-builder/templates/template-icons.ts +++ b/apps/web/src/components/dashboard-builder/templates/template-icons.ts @@ -5,6 +5,7 @@ import { CloudflareIcon, DatabaseIcon, GlobeIcon, + GoogleAnalyticsIcon, GridSquareCirclePlusIcon, type IconComponent, KafkaIcon, @@ -42,6 +43,7 @@ const TEMPLATE_ICONS: Record = { "mongodb-overview": MongodbIcon, cloudflare: CloudflareIcon, planetscale: PlanetScaleIcon, + "google-analytics": GoogleAnalyticsIcon, "host-metrics": ServerIcon, "kubernetes-cluster": KubernetesIcon, "kubernetes-pod": KubernetesIcon, @@ -56,6 +58,7 @@ const CATEGORY_ICONS: Record = { database: DatabaseIcon, infrastructure: ServerIcon, messaging: PaperPlaneIcon, + product: ChartLineIcon, } satisfies Record export function templateIcon(templateId: string, category: string): IconComponent { diff --git a/apps/web/src/components/icons/google-analytics.tsx b/apps/web/src/components/icons/google-analytics.tsx new file mode 100644 index 000000000..a196f69dc --- /dev/null +++ b/apps/web/src/components/icons/google-analytics.tsx @@ -0,0 +1,21 @@ +import type { IconProps } from "./icon" + +// Source: simple-icons (MIT) — https://simpleicons.org/icons/googleanalytics +function GoogleAnalyticsIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} + +export { GoogleAnalyticsIcon } diff --git a/apps/web/src/components/icons/index.ts b/apps/web/src/components/icons/index.ts index ad71d1018..b88b78ebb 100644 --- a/apps/web/src/components/icons/index.ts +++ b/apps/web/src/components/icons/index.ts @@ -113,6 +113,7 @@ export { FolderIcon } from "./folder" export { GearIcon } from "./gear" export { GeminiIcon } from "./gemini" export { GithubIcon } from "./github" +export { GoogleAnalyticsIcon } from "./google-analytics" export { GoogleIcon } from "./google" export { GrokIcon } from "./grok" export { HaystackIcon } from "./haystack" diff --git a/apps/web/src/components/integrations/google-analytics-integration-card.tsx b/apps/web/src/components/integrations/google-analytics-integration-card.tsx new file mode 100644 index 000000000..501f7248b --- /dev/null +++ b/apps/web/src/components/integrations/google-analytics-integration-card.tsx @@ -0,0 +1,247 @@ +import { useState } from "react" +import { Exit } from "effect" +import { Badge } from "@maple/ui/components/ui/badge" +import { Button } from "@maple/ui/components/ui/button" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Switch } from "@maple/ui/components/ui/switch" +import { toastManager } from "@maple/ui/components/ui/toast" + +import { GoogleAnalyticsIcon, LoaderIcon } from "@/components/icons" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" +import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" +import { showErrorToast } from "@/lib/error-toast" +import { IntegrationIconPlate, catalogEntry } from "./integration-catalog" +import { useIntegrationConnect } from "./integration-connect" +import { + IntegrationEmpty, + IntegrationEmptyCard, + IntegrationEmptyFeature, + IntegrationEmptyFeatures, + IntegrationEmptyHint, + IntegrationEmptyMedia, +} from "./integration-empty-state" + +const GA_ENTRY = catalogEntry("google-analytics") + +/** + * Google Analytics 4 connection card: authorize a Google account in a popup, and every GA4 + * property that account can see is discovered and collected automatically. Per-property toggles + * are the one knob — an agency account reaching hundreds of properties should not have to collect + * all of them. + * + * Disconnect lives in the card body rather than the route header, matching PlanetScale and Slack; + * Cloudflare's header actions are the outlier. + */ +export function GoogleAnalyticsIntegrationCard() { + const statusQuery = retainedQueryV2("googleAnalyticsIntegration", "status", { + reactivityKeys: ["googleAnalyticsIntegration"], + }) + const statusResult = useAtomValue(statusQuery) + const refreshStatus = useAtomRefresh(statusQuery) + + const connectFlow = useIntegrationConnect() + if (connectFlow === null) { + throw new Error("GoogleAnalyticsIntegrationCard must render inside IntegrationConnectProvider") + } + + const disconnect = useAtomSet( + MapleApiV2AtomClient.mutation("googleAnalyticsIntegration", "disconnect"), + { mode: "promiseExit" }, + ) + const updateProperty = useAtomSet( + MapleApiV2AtomClient.mutation("googleAnalyticsIntegration", "updateProperty"), + { mode: "promiseExit" }, + ) + + const handleDisconnect = async () => { + const result = await disconnect({ reactivityKeys: ["googleAnalyticsIntegration"] }) + if (Exit.isSuccess(result)) { + toastManager.add({ title: "Google Analytics disconnected", type: "success" }) + refreshStatus() + } else { + showErrorToast(result, { fallbackTitle: "Failed to disconnect Google Analytics" }) + } + } + + // Which property's toggle is in flight — and every row is disabled while one is, not just that + // row. The mutation atom is shared and non-concurrent, so a second toggle interrupts the first + // one's client-side operation without cancelling the PATCH that already reached the API, and + // `setPropertyEnabled` is an unconditional write with no ordering check: the older request can + // land last and undo the user's final choice. Per-row disabling was not enough on its own — + // whichever operation settled first cleared this back to null and re-enabled every row while + // another update was still in flight. + const [pendingProperty, setPendingProperty] = useState(null) + + const handleToggle = async (propertyId: string, enabled: boolean) => { + setPendingProperty(propertyId) + const result = await updateProperty({ + params: { property_id: propertyId }, + payload: { enabled }, + reactivityKeys: ["googleAnalyticsIntegration"], + }) + setPendingProperty(null) + if (Exit.isSuccess(result)) { + refreshStatus() + } else { + showErrorToast(result, { fallbackTitle: "Failed to update the property" }) + } + } + + if (Result.isInitial(statusResult)) { + return + } + + // A failed status fetch is not "not connected" — don't offer the connect CTA over an + // account that may already be authorized. + if (Result.isFailure(statusResult)) { + return ( +
+ +
+

Google Analytics

+

+ Couldn't load the Google Analytics connection status — refresh the page to try + again. +

+
+
+ ) + } + + const status = statusResult.value + + if (!status.connected) { + return ( + + + + + + + + + + Every GA4 property the Google account can see appears here after connecting. + + + + + ) + } + + return ( +
+
+
+ +
+
+

Google Analytics

+ {status.revoked ? ( + Reconnect needed + ) : ( + Connected + )} +
+

+ {status.connected_email ?? "Connected Google account"} +

+
+
+
+ {status.revoked && ( + + )} + +
+
+ + {status.revoked && ( +

+ Google rejected the stored authorization, so collection has stopped. Everything already + collected is still here — reconnect to resume. +

+ )} + +
+ {status.properties.length === 0 ? ( +

+ No GA4 properties discovered yet. Discovery runs hourly after connecting. +

+ ) : ( +
    + {status.properties.map((property) => ( +
  • +
    +
    + + {property.property_name ?? property.property_id} + + + {property.property_id} + +
    +

    + {propertyDetail(property)} +

    +
    + void handleToggle(property.property_id, checked)} + aria-label={`Collect ${property.property_name ?? property.property_id}`} + /> +
  • + ))} +
+ )} +
+
+ ) +} + +type PropertyStatus = { + readonly enabled: boolean + readonly account_name: string | null + readonly time_zone: string | null + readonly last_error: string | null + readonly last_synced_at: string | null +} + +/** The one line under a property's name: whatever most needs saying about it. */ +function propertyDetail(property: PropertyStatus): string { + if (!property.enabled) return "Not collected" + if (property.last_error !== null) return property.last_error + // No timezone means the property has never been collected — GA4 reports hourly data in the + // property's own zone, so nothing can be placed on the timeline until it resolves. + if (property.time_zone === null) return "Waiting for the first collection" + const account = property.account_name ?? "Google Analytics" + return property.last_synced_at === null + ? account + : `${account} · synced ${formatRelativeTime(property.last_synced_at)}` +} diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index d48d1adbb..5e05e9aba 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -8,6 +8,7 @@ import { ChevronRightIcon, CloudflareIcon, GithubIcon, + GoogleAnalyticsIcon, HazelIcon, PlanetScaleIcon, PrometheusIcon, @@ -29,6 +30,7 @@ export type IntegrationId = | "hazel" | "github" | "slack" + | "google-analytics" /** * Third-party brand accents for the icon-plate wash — no app token applies. @@ -37,6 +39,8 @@ export type IntegrationId = export const GITHUB_ACCENT = "#181717" export const HAZEL_ACCENT = "#F46F0F" export const CLOUDFLARE_ACCENT = "#F38020" +/** Google Analytics 4 brand orange. */ +export const GOOGLE_ANALYTICS_ACCENT = "#E37400" /** * Slack's deep aubergine — the brand's identity color, and the light-theme value. @@ -148,6 +152,15 @@ const CATALOG: ReadonlyArray = [ accent: SLACK_ACCENT, docsUrl: "https://maple.dev/docs/integrations/slack", }, + { + id: "google-analytics", + name: "Google Analytics", + description: + "Connect a Google account to chart GA4 sessions, users and page views next to your traces and errors.", + icon: GoogleAnalyticsIcon, + accent: GOOGLE_ANALYTICS_ACCENT, + docsUrl: "https://maple.dev/docs/integrations/google-analytics", + }, ] /** @@ -210,6 +223,11 @@ export function useIntegrationStatuses(): Partial null) .orElse(() => STATUS_UNAVAILABLE) + const googleAnalytics: CardStatus | null = Result.builder(googleAnalyticsResult) + .onSuccess((status): CardStatus => { + if (!status.connected) return NOT_CONNECTED + // A revoked grant still has a connection row, so "Not connected" would be wrong and + // "Connected" would be a lie — collection has stopped until someone reconnects. + if (status.revoked) return { label: "Reconnect needed", variant: "warning" } + const collecting = status.properties.filter((property) => property.enabled).length + return { + label: collecting > 0 ? `${collecting} propert${collecting === 1 ? "y" : "ies"}` : "Connected", + variant: "success", + } + }) + .onInitial(() => null) + .orElse(() => STATUS_UNAVAILABLE) + return { cloudflare, prometheus: scrapeStatus("prometheus"), @@ -294,6 +327,7 @@ export function useIntegrationStatuses(): Partial { @@ -614,6 +653,40 @@ export function useIntegrationOverviews(): Record null) .orElse(() => UNAVAILABLE) + const googleAnalytics: IntegrationOverview = Result.builder(googleAnalyticsResult) + .onSuccess((status): IntegrationOverview => { + if (!status.connected) return CONNECT + const collecting = status.properties.filter((property) => property.enabled) + const erroring = collecting.filter((property) => property.last_error !== null) + // A property with no timezone yet has never been collected — Google reports hourly + // data in the property's own zone, so nothing can be placed until it resolves. + const unresolved = collecting.filter((property) => property.time_zone === null) + const issue = status.revoked + ? "authorization revoked" + : erroring.length > 0 + ? `${plural(erroring.length, "property")} erroring` + : unresolved.length > 0 + ? `${plural(unresolved.length, "property")} not started` + : null + return { + kind: "connected", + health: issue ? "attention" : "healthy", + stateLabel: status.revoked ? "Reconnect needed" : issue ? "Needs attention" : "Healthy", + context: status.connected_email, + stat: collecting.length > 0 ? `${plural(collecting.length, "property")} collected` : null, + lastSyncLabel: syncedLabel( + maxMs( + collecting.map((property) => + property.last_synced_at ? Date.parse(property.last_synced_at) : null, + ), + ), + ), + issue, + } + }) + .onInitial(() => null) + .orElse(() => UNAVAILABLE) + return { cloudflare, prometheus: scrapeOverview("prometheus"), @@ -623,6 +696,7 @@ export function useIntegrationOverviews(): Record{children} case "planetscale": return {children} + case "google-analytics": + return {children} default: return children } @@ -389,3 +391,65 @@ function PlanetscaleConnectBoundary({ children }: { children: React.ReactNode }) return {children} } + +function GoogleAnalyticsConnectBoundary({ children }: { children: React.ReactNode }) { + const refreshStatus = useAtomRefresh( + retainedQueryV2("googleAnalyticsIntegration", "status", { + reactivityKeys: ["googleAnalyticsIntegration"], + }), + ) + const startConnect = useAtomSet( + MapleApiV2AtomClient.mutation("googleAnalyticsIntegration", "connect"), + { mode: "promiseExit" }, + ) + const prime = useAtomSet(MapleApiV2AtomClient.mutation("googleAnalyticsIntegration", "prime"), { + mode: "promiseExit", + }) + + // The callback deliberately does NOT run the first collection — it takes tens of seconds on a + // grant with several properties and the popup would sit blank for all of it. This tab is still + // open, so it runs it here. + // + // Two paths can reach it: the success message, and the popup simply closing (`postMessage` is + // lost under COOP, which `useOAuthPopupFlow` documents). Both are needed — without the close + // path, a COOP-blocked browser connects successfully and then waits up to fifteen minutes for + // the cron before showing a single number. The ref is what stops them running it twice. + const primed = useRef(false) + const primeOnce = useEffectEvent(() => { + if (primed.current) return + primed.current = true + void prime({ reactivityKeys: ["googleAnalyticsIntegration"] }).finally(refreshStatus) + }) + + useIntegrationMessage("maple:integration:google-analytics", (data) => { + if (data.status === "success") { + primeOnce() + refreshStatus() + } else if (data.status === "error") { + toastManager.add({ title: data.message ?? "Google Analytics connection failed", type: "error" }) + } + }) + + const value = useOAuthPopupFlow({ + windowName: "maple-google-analytics-connect", + label: "Google Analytics", + windowFeatures: "popup,width=520,height=680", + start: () => { + // Per ATTEMPT, not per mount: the card offers Reconnect on a revoked grant without + // unmounting this boundary, so a latched `primed` would skip the first collection on + // every attempt after the first and leave the reconnect waiting on cron. + primed.current = false + return startConnect({ + payload: { return_to: currentReturnPath() }, + reactivityKeys: ["googleAnalyticsIntegration"], + }).then(Exit.map(({ redirect_url }) => ({ redirectUrl: redirect_url }))) + }, + startErrorTitle: "Failed to start Google Analytics connect flow", + onClosed: () => { + refreshStatus() + primeOnce() + }, + }) + + return {children} +} diff --git a/apps/web/src/components/layout/dashboard-layout.tsx b/apps/web/src/components/layout/dashboard-layout.tsx index a0e404c96..b25cacbfa 100644 --- a/apps/web/src/components/layout/dashboard-layout.tsx +++ b/apps/web/src/components/layout/dashboard-layout.tsx @@ -79,12 +79,22 @@ function Breadcrumbs({ items, children }: { items: BreadcrumbEntry[]; children?:
- - + {/* The header is a fixed `h-16`, so a wrapping trail does not grow it — it spills out + and gets clipped by the border. Kept to one line instead: see the per-crumb rules + below for what gives way when a trail like "Settings › Integrations › Google + Analytics" meets a 375px viewport. */} + + {items.map((item, index) => ( - {index > 0 && } - + {index > 0 && } + {/* Below `sm` only the leaf survives: the full trail cannot share a + 375px row with the action cluster, and letting the ancestors shrink + instead collapses every crumb at once so their un-truncated link + text overlaps. Above `sm` the whole trail is back. */} + {item.href ? ( (() => { const { pathname, search } = parseSearchFromHref(item.href) @@ -104,7 +114,7 @@ function Breadcrumbs({ items, children }: { items: BreadcrumbEntry[]; children?: ) })() ) : ( - {item.label} + {item.label} )} diff --git a/apps/web/src/routes/integrations.tsx b/apps/web/src/routes/integrations.tsx index c40f8c674..42604033b 100644 --- a/apps/web/src/routes/integrations.tsx +++ b/apps/web/src/routes/integrations.tsx @@ -11,6 +11,7 @@ import { import { GithubIntegrationCard } from "@/components/integrations/github-integration-card" import { HazelIntegrationCard } from "@/components/integrations/hazel-integration-card" import { PlanetScaleIntegrationCard } from "@/components/integrations/planetscale-integration-card" +import { GoogleAnalyticsIntegrationCard } from "@/components/integrations/google-analytics-integration-card" import { SlackIntegrationCard } from "@/components/integrations/slack-integration-card" import { IntegrationCatalog, @@ -226,6 +227,8 @@ function IntegrationsPage() { ) : integration === "slack" ? ( + ) : integration === "google-analytics" ? ( + ) : ( // prometheus + warpstream share the generic scrape-target flow @@ -260,7 +263,11 @@ function IntegrationHeader({ integration }: { integration: IntegrationId }) { : null return ( -
+ // Wraps rather than overflowing: the action group is `shrink-0` (a Connect button that + // shrinks becomes an ellipsis), so on a narrow viewport it has to move to its own row or + // it runs off the edge. `entry.name` is the other half — the longest one in the catalog is + // "Google Analytics", which is what made the collision obvious. +