Skip to content

Commit ef986b3

Browse files
committed
feat(google-analytics): add Google Analytics 4 integration
Adds a Google Analytics block backed by the GA4 Data and Admin APIs, with property and account selectors, OAuth wiring, and generated docs.
1 parent 656840a commit ef986b3

40 files changed

Lines changed: 4114 additions & 6 deletions

apps/docs/components/icons.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5513,6 +5513,21 @@ export const GoogleAdsIcon = (props: SVGProps<SVGSVGElement>) => (
55135513
</svg>
55145514
)
55155515

5516+
export const GoogleAnalyticsIcon = (props: SVGProps<SVGSVGElement>) => (
5517+
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
5518+
<g transform='matrix(.363638 0 0 .363636 -3.272763 -2.909091)'>
5519+
<path
5520+
d='M130 29v132c0 14.77 10.2 23 21 23 10 0 21-7 21-23V30c0-13.54-10-22-21-22s-21 9.33-21 21z'
5521+
fill='#f9ab00'
5522+
/>
5523+
<g fill='#e37400'>
5524+
<path d='M75 96v65c0 14.77 10.2 23 21 23 10 0 21-7 21-23V97c0-13.54-10-22-21-22s-21 9.33-21 21z' />
5525+
<circle cx='41' cy='163' r='21' />
5526+
</g>
5527+
</g>
5528+
</svg>
5529+
)
5530+
55165531
export const GoogleBigQueryIcon = (props: SVGProps<SVGSVGElement>) => (
55175532
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'>
55185533
<path

apps/docs/components/ui/icon-mapping.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import {
9090
GmailIcon,
9191
GongIcon,
9292
GoogleAdsIcon,
93+
GoogleAnalyticsIcon,
9394
GoogleAppsheetIcon,
9495
GoogleBigQueryIcon,
9596
GoogleBooksIcon,
@@ -366,6 +367,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
366367
gmail_v2: GmailIcon,
367368
gong: GongIcon,
368369
google_ads: GoogleAdsIcon,
370+
google_analytics: GoogleAnalyticsIcon,
369371
google_appsheet: GoogleAppsheetIcon,
370372
google_bigquery: GoogleBigQueryIcon,
371373
google_books: GoogleBooksIcon,

apps/docs/content/docs/en/integrations/google_analytics.mdx

Lines changed: 371 additions & 0 deletions
Large diffs are not rendered by default.

apps/docs/content/docs/en/integrations/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"gong",
9393
"google-service-account",
9494
"google_ads",
95+
"google_analytics",
9596
"google_appsheet",
9697
"google_bigquery",
9798
"google_books",
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { googleAnalyticsAccountsSelectorContract } from '@/lib/api/contracts/selectors/google-analytics'
4+
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import {
9+
refreshAccessTokenIfNeeded,
10+
ServiceAccountTokenError,
11+
} from '@/lib/oauth/credential-service'
12+
import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination'
13+
import { getScopesForService } from '@/lib/oauth/utils'
14+
15+
const logger = createLogger('GoogleAnalyticsAccountsAPI')
16+
17+
export const dynamic = 'force-dynamic'
18+
19+
const MAX_ACCOUNT_PAGES = 10
20+
const ACCOUNT_PAGE_SIZE = 200
21+
22+
interface AnalyticsAccount {
23+
name?: string
24+
displayName?: string
25+
}
26+
27+
interface AccountsResponse {
28+
accounts?: AnalyticsAccount[]
29+
nextPageToken?: string
30+
}
31+
32+
/**
33+
* POST /api/tools/google_analytics/accounts
34+
*
35+
* Lists the Google Analytics accounts the caller can administer, for the account
36+
* picker behind the List Properties operation.
37+
*/
38+
export const POST = withRouteHandler(async (request: NextRequest) => {
39+
const requestId = generateRequestId()
40+
try {
41+
const parsed = await parseRequest(
42+
googleAnalyticsAccountsSelectorContract,
43+
request,
44+
{},
45+
{
46+
validationErrorResponse: (error) => {
47+
const path = error.issues.at(0)?.path[0]
48+
const message =
49+
path === 'credential'
50+
? 'Credential is required'
51+
: getValidationErrorMessage(error, 'Invalid request')
52+
logger.error(`Validation failed for Google Analytics accounts request: ${message}`)
53+
return NextResponse.json({ error: message }, { status: 400 })
54+
},
55+
}
56+
)
57+
if (!parsed.success) return parsed.response
58+
59+
const { credential, workflowId, impersonateEmail } = parsed.data.body
60+
61+
const authz = await authorizeCredentialUse(request, {
62+
credentialId: credential,
63+
workflowId,
64+
})
65+
if (!authz.ok || !authz.credentialOwnerUserId) {
66+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
67+
}
68+
69+
const accessToken = await refreshAccessTokenIfNeeded(
70+
credential,
71+
authz.credentialOwnerUserId,
72+
requestId,
73+
getScopesForService('google-analytics'),
74+
impersonateEmail
75+
)
76+
if (!accessToken) {
77+
logger.error('Failed to get access token', {
78+
credentialId: credential,
79+
userId: authz.credentialOwnerUserId,
80+
})
81+
return NextResponse.json(
82+
{ error: 'Could not retrieve access token', authRequired: true },
83+
{ status: 401 }
84+
)
85+
}
86+
87+
const { items } = await drainGooglePagedList<AnalyticsAccount, AccountsResponse>({
88+
buildUrl: (pageToken) => {
89+
const url = new URL('https://analyticsadmin.googleapis.com/v1beta/accounts')
90+
url.searchParams.set('pageSize', String(ACCOUNT_PAGE_SIZE))
91+
if (pageToken) url.searchParams.set('pageToken', pageToken)
92+
return url.toString()
93+
},
94+
fetch: (url) =>
95+
fetch(url, {
96+
headers: {
97+
Authorization: `Bearer ${accessToken}`,
98+
'Content-Type': 'application/json',
99+
},
100+
}),
101+
parseError: (response) => response.json().catch(() => ({})),
102+
getItems: (body) => body.accounts,
103+
getNextPageToken: (body) => body.nextPageToken,
104+
maxPages: MAX_ACCOUNT_PAGES,
105+
label: 'Google Analytics accounts',
106+
})
107+
108+
const accounts = items.flatMap((account) =>
109+
account.name ? [{ name: account.name, displayName: account.displayName }] : []
110+
)
111+
112+
return NextResponse.json({ accounts })
113+
} catch (error) {
114+
if (error instanceof GooglePageError) {
115+
logger.error('Failed to fetch Google Analytics accounts', {
116+
status: error.status,
117+
error: error.body,
118+
})
119+
return NextResponse.json(
120+
{ error: 'Failed to fetch Google Analytics accounts', details: error.body },
121+
{ status: error.status }
122+
)
123+
}
124+
if (error instanceof ServiceAccountTokenError) {
125+
return NextResponse.json({ error: error.message }, { status: 400 })
126+
}
127+
logger.error('Error processing Google Analytics accounts request:', error)
128+
return NextResponse.json(
129+
{ error: 'Failed to retrieve Google Analytics accounts', details: (error as Error).message },
130+
{ status: 500 }
131+
)
132+
}
133+
})
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { googleAnalyticsPropertiesSelectorContract } from '@/lib/api/contracts/selectors/google-analytics'
4+
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
5+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import {
9+
refreshAccessTokenIfNeeded,
10+
ServiceAccountTokenError,
11+
} from '@/lib/oauth/credential-service'
12+
import { drainGooglePagedList, GooglePageError } from '@/lib/oauth/google-pagination'
13+
import { getScopesForService } from '@/lib/oauth/utils'
14+
15+
const logger = createLogger('GoogleAnalyticsPropertiesAPI')
16+
17+
export const dynamic = 'force-dynamic'
18+
19+
const MAX_ACCOUNT_SUMMARY_PAGES = 10
20+
const ACCOUNT_SUMMARY_PAGE_SIZE = 200
21+
22+
interface AccountSummary {
23+
account?: string
24+
displayName?: string
25+
propertySummaries?: Array<{ property?: string; displayName?: string }>
26+
}
27+
28+
interface AccountSummariesResponse {
29+
accountSummaries?: AccountSummary[]
30+
nextPageToken?: string
31+
}
32+
33+
/**
34+
* POST /api/tools/google_analytics/properties
35+
*
36+
* Lists every GA4 property the caller can reach, flattened out of the Admin API's
37+
* account summaries so one request covers all accounts. Each entry carries its
38+
* owning account's display name so properties that share a name stay distinguishable.
39+
*/
40+
export const POST = withRouteHandler(async (request: NextRequest) => {
41+
const requestId = generateRequestId()
42+
try {
43+
const parsed = await parseRequest(
44+
googleAnalyticsPropertiesSelectorContract,
45+
request,
46+
{},
47+
{
48+
validationErrorResponse: (error) => {
49+
const path = error.issues.at(0)?.path[0]
50+
const message =
51+
path === 'credential'
52+
? 'Credential is required'
53+
: getValidationErrorMessage(error, 'Invalid request')
54+
logger.error(`Validation failed for Google Analytics properties request: ${message}`)
55+
return NextResponse.json({ error: message }, { status: 400 })
56+
},
57+
}
58+
)
59+
if (!parsed.success) return parsed.response
60+
61+
const { credential, workflowId, impersonateEmail } = parsed.data.body
62+
63+
const authz = await authorizeCredentialUse(request, {
64+
credentialId: credential,
65+
workflowId,
66+
})
67+
if (!authz.ok || !authz.credentialOwnerUserId) {
68+
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
69+
}
70+
71+
const accessToken = await refreshAccessTokenIfNeeded(
72+
credential,
73+
authz.credentialOwnerUserId,
74+
requestId,
75+
getScopesForService('google-analytics'),
76+
impersonateEmail
77+
)
78+
if (!accessToken) {
79+
logger.error('Failed to get access token', {
80+
credentialId: credential,
81+
userId: authz.credentialOwnerUserId,
82+
})
83+
return NextResponse.json(
84+
{ error: 'Could not retrieve access token', authRequired: true },
85+
{ status: 401 }
86+
)
87+
}
88+
89+
const { items } = await drainGooglePagedList<AccountSummary, AccountSummariesResponse>({
90+
buildUrl: (pageToken) => {
91+
const url = new URL('https://analyticsadmin.googleapis.com/v1beta/accountSummaries')
92+
url.searchParams.set('pageSize', String(ACCOUNT_SUMMARY_PAGE_SIZE))
93+
if (pageToken) url.searchParams.set('pageToken', pageToken)
94+
return url.toString()
95+
},
96+
fetch: (url) =>
97+
fetch(url, {
98+
headers: {
99+
Authorization: `Bearer ${accessToken}`,
100+
'Content-Type': 'application/json',
101+
},
102+
}),
103+
parseError: (response) => response.json().catch(() => ({})),
104+
getItems: (body) => body.accountSummaries,
105+
getNextPageToken: (body) => body.nextPageToken,
106+
maxPages: MAX_ACCOUNT_SUMMARY_PAGES,
107+
label: 'Google Analytics account summaries',
108+
})
109+
110+
const properties = items.flatMap((summary) =>
111+
(summary.propertySummaries ?? []).flatMap((property) =>
112+
property.property
113+
? [
114+
{
115+
property: property.property,
116+
displayName: property.displayName,
117+
accountDisplayName: summary.displayName,
118+
},
119+
]
120+
: []
121+
)
122+
)
123+
124+
return NextResponse.json({ properties })
125+
} catch (error) {
126+
if (error instanceof GooglePageError) {
127+
logger.error('Failed to fetch Google Analytics properties', {
128+
status: error.status,
129+
error: error.body,
130+
})
131+
return NextResponse.json(
132+
{ error: 'Failed to fetch Google Analytics properties', details: error.body },
133+
{ status: error.status }
134+
)
135+
}
136+
if (error instanceof ServiceAccountTokenError) {
137+
return NextResponse.json({ error: error.message }, { status: 400 })
138+
}
139+
logger.error('Error processing Google Analytics properties request:', error)
140+
return NextResponse.json(
141+
{
142+
error: 'Failed to retrieve Google Analytics properties',
143+
details: (error as Error).message,
144+
},
145+
{ status: 500 }
146+
)
147+
}
148+
})

0 commit comments

Comments
 (0)