Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1600b3e
feat(ga4): collector foundation — env, schema, and delta reconciliation
Makisuo Sep 8, 2026
0a922be
feat(ga4): OAuth service and the collector poll loop
Makisuo Sep 8, 2026
19ced8a
feat(ga4): v2 API surface, OAuth callback, and cron wiring
Makisuo Sep 9, 2026
9974d5f
feat(ga4): dashboard template, integration card, and catalog entry
Makisuo Sep 9, 2026
29e7b04
fix(ga4): regenerate the iOS OpenAPI spec and raise the web bundle bu…
Makisuo Sep 9, 2026
758a47d
fix(ga4): register the collector tables in the org-scoped table registry
Makisuo Sep 9, 2026
4bc99e6
fix(ga4): report pagination, 403 handling, and reconnect double-counting
Makisuo Sep 9, 2026
b746bab
fix(ga4): purge the collector tables on organization deletion
Makisuo Sep 9, 2026
90b4351
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 9, 2026
8fe73cd
fix(ga4): charge the call budget per request, isolate timezone failures
Makisuo Sep 9, 2026
70af5c9
feat(ga4): use the real Google Analytics brand mark
Makisuo Sep 9, 2026
fa47805
fix(web): stop the integration header and breadcrumb breaking on narr…
Makisuo Sep 9, 2026
928a9ec
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 10, 2026
0b5a3bc
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 11, 2026
b2ecfdd
fix(web): raise the startup budget to cover both merged additions
Makisuo Sep 11, 2026
20361a1
fix(api): assert the tool-error span prune on its real key
Makisuo Sep 11, 2026
44f5f9d
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 11, 2026
eccdfaf
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 11, 2026
852bc47
test(api): stub Google Analytics in the telemetry-signals harness
Makisuo Sep 11, 2026
1a19655
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 11, 2026
1cb2845
fix(web): re-measure the startup budget on the #865 merge
Makisuo Sep 11, 2026
e8a3f79
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 12, 2026
0615219
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 13, 2026
766d567
test(backend): seed the GA test grant with a tagged error for effect-…
Makisuo Sep 13, 2026
7d2e9e2
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 13, 2026
f7e6e71
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 14, 2026
5fd2dd6
test(api): stub the GA service in the onboarding checklist v2 harness
Makisuo Sep 14, 2026
7140d92
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 14, 2026
df67378
Merge remote-tracking branch 'origin/main' into worktree-google-analy…
Makisuo Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/ai/src/runtime/graph-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe("AI runtime graph boundaries", () => {
for (const routeOnlyService of [
"DailySpendService",
"CloudflareAnalyticsService",
"GoogleAnalyticsService",
"AnomalyDetectionService",
"AiTriageService",
"DigestService",
Expand Down
5 changes: 4 additions & 1 deletion apps/alerting/src/scheduled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
24 changes: 23 additions & 1 deletion apps/alerting/src/scheduled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -37,6 +38,7 @@ export const buildLayer = (env: AlertingWorkerEnv) =>
AlertsService.layer,
AnomalyDetectionService.layer,
CloudflareAnalyticsService.layer,
GoogleAnalyticsService.layer,
PlanetScaleService.layer,
DigestService.layer,
ErrorsService.layer,
Expand Down Expand Up @@ -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",
Expand All @@ -233,6 +250,7 @@ export interface ScheduledTickPrograms<R = never> {
readonly error: Effect.Effect<void, never, R>
readonly escalation: Effect.Effect<void, never, R>
readonly fixVerification: Effect.Effect<void, never, R>
readonly googleAnalytics: Effect.Effect<void, never, R>
readonly planetScale: Effect.Effect<void, never, R>
readonly serviceMapRollup: Effect.Effect<void, never, R>
}
Expand All @@ -253,7 +271,9 @@ export const selectScheduledProgram = <R>(
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:
Expand Down Expand Up @@ -283,6 +303,7 @@ type ScheduledServices =
| ErrorsService
| EscalationService
| FixVerificationTickService
| GoogleAnalyticsService
| PlanetScaleService
| ServiceMapRollupService

Expand All @@ -294,6 +315,7 @@ export const scheduledTicks: ScheduledTickPrograms<ScheduledServices> = {
error: errorTick,
escalation: escalationTick,
fixVerification: fixVerificationTick,
googleAnalytics: googleAnalyticsTick,
planetScale: planetScaleTick,
serviceMapRollup: serviceMapRollupTick,
}
Expand Down
8 changes: 5 additions & 3 deletions apps/alerting/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
appUrlsEnv,
authEnv,
cloudflareOAuthEnv,
googleAnalyticsOAuthEnv,
ingestKeyCryptoEnv,
merge,
optionalPlain,
Expand Down Expand Up @@ -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,
)

/**
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/resources/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
authEnv,
cloudflareOAuthEnv,
derived,
googleAnalyticsOAuthEnv,
ingestKeyCryptoEnv,
merge,
optionalPlain,
Expand Down Expand Up @@ -107,4 +108,5 @@ export const apiConfiguredEnv = (stage: MapleStage, domains: MapleDomains) =>
optionalPlain("GITHUB_API_BASE_URL"),
cloudflareOAuthEnv,
planetScaleOAuthEnv,
googleAnalyticsOAuthEnv,
)
92 changes: 92 additions & 0 deletions apps/api/src/routes/v1/integrations.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -850,6 +856,14 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) =>
messageType: CLOUDFLARE_MESSAGE_TYPE,
label: "Cloudflare",
})
const googleAnalyticsCallbackPage = (params: Omit<CallbackPageParams, "targetOrigin">) =>
renderCallbackPage({
...params,
targetOrigin: dashboardTargetOrigin,
messageType: GOOGLE_ANALYTICS_MESSAGE_TYPE,
label: "Google Analytics",
})

const planetscaleCallbackPage = (params: Omit<CallbackPageParams, "targetOrigin">) =>
renderCallbackPage({
...params,
Expand Down Expand Up @@ -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)
}),
)
2 changes: 2 additions & 0 deletions apps/api/src/routes/v2/alchemy-provider.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
ApiV2RateLimiterAllowAllLayer,
ConfigResourceServiceStubsLayer,
makeWarehouseServiceStub,
GoogleAnalyticsServiceStubsLayer,
PlanetScaleServiceStubsLayer,
SlackIntegrationServiceStubLayer,
TelemetryServiceStubsLayer,
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/v2/alerts.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
ApiV2RateLimiterAllowAllLayer,
ConfigResourceServiceStubsLayer,
makeWarehouseServiceStub,
GoogleAnalyticsServiceStubsLayer,
PlanetScaleServiceStubsLayer,
SlackIntegrationServiceStubLayer,
TelemetryServiceStubsLayer,
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/v2/api-keys.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
AlertsServiceStubLayer,
AllV2GroupLayersLive,
ConfigResourceServiceStubsLayer,
GoogleAnalyticsServiceStubsLayer,
PlanetScaleServiceStubsLayer,
SlackIntegrationServiceStubLayer,
TelemetryServiceStubsLayer,
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/v2/config-resources.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
ApiV2RateLimiterAllowAllLayer,
makeWarehouseServiceStub,
Phase1ResourceStubsLayer,
GoogleAnalyticsServiceStubsLayer,
PlanetScaleServiceStubsLayer,
SlackIntegrationServiceStubLayer,
SetupAuditServiceStubLayer,
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/v2/dashboards.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
AllV2GroupLayersLive,
ApiV2RateLimiterAllowAllLayer,
ConfigResourceServiceStubsLayer,
GoogleAnalyticsServiceStubsLayer,
PlanetScaleServiceStubsLayer,
SlackIntegrationServiceStubLayer,
TelemetryServiceStubsLayer,
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/v2/integrations.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
AllV2GroupLayersLive,
ApiV2RateLimiterAllowAllLayer,
ConfigResourceServiceStubsLayer,
GoogleAnalyticsServiceStubsLayer,
TelemetryServiceStubsLayer,
} from "./v2-test-support"

Expand Down Expand Up @@ -170,6 +171,7 @@ const makeHarness = (slack: Partial<SlackIntegrationServiceApi> = {}, planetscal
Layer.provide(V2TransportErrorBoundaryLive),
Layer.provide(slackServiceLayer(slack)),
Layer.provide(planetscaleServiceLayer(planetscale)),
Layer.provide(GoogleAnalyticsServiceStubsLayer),
Layer.provide(AlertsServiceStubLayer),
Layer.provide(ConfigResourceServiceStubsLayer),
Layer.provide(TelemetryServiceStubsLayer),
Expand Down
Loading
Loading