Skip to content

Commit 8b74a23

Browse files
committed
fix(google-analytics): correct metadata schema and null-safe optional params
- DimensionMetadata has no dataType field; split dimension/metric metadata shapes and expose category, plus expression on metrics - Treat null pageSize/limit/offset as omitted so they are not serialized - Bound provider error body reads and surface pagination truncation - Keep the property scope on report canvas cards
1 parent 828beb0 commit 8b74a23

14 files changed

Lines changed: 159 additions & 44 deletions

File tree

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,14 +207,16 @@ List the dimensions and metrics available for reporting on a Google Analytics 4
207207
|`apiName` | string | Name used in report requests |
208208
|`uiName` | string | Name shown in the GA4 UI |
209209
|`description` | string | What the dimension measures |
210-
|`type` | string | Value data type |
210+
|`category` | string | Grouping the dimension belongs to, e.g. Page / Screen |
211211
|`customDefinition` | boolean | Whether this is a property-specific custom dimension |
212212
|`deprecatedApiNames` | array | Still-accepted deprecated names |
213213
| `metrics` | array | Metrics available for reporting |
214214
|`apiName` | string | Name used in report requests |
215215
|`uiName` | string | Name shown in the GA4 UI |
216216
|`description` | string | What the metric measures |
217-
|`type` | string | Value data type |
217+
|`type` | string | Metric value type |
218+
|`expression` | string | Formula for a derived metric |
219+
|`category` | string | Grouping the metric belongs to, e.g. Session |
218220
|`customDefinition` | boolean | Whether this is a property-specific custom metric |
219221
|`deprecatedApiNames` | array | Still-accepted deprecated names |
220222
| `totalDimensions` | number | Number of dimensions returned |

apps/sim/app/api/tools/google_analytics/accounts/route.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { googleAnalyticsAccountsSelectorContract } from '@/lib/api/contracts/sel
44
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
55
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
66
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
89
import {
910
refreshAccessTokenIfNeeded,
@@ -16,6 +17,9 @@ const logger = createLogger('GoogleAnalyticsAccountsAPI')
1617

1718
export const dynamic = 'force-dynamic'
1819

20+
/** Provider error bodies are echoed into logs and the response, so cap what we read. */
21+
const MAX_ERROR_BODY_BYTES = 32 * 1024
22+
1923
const MAX_ACCOUNT_PAGES = 10
2024
const ACCOUNT_PAGE_SIZE = 200
2125

@@ -84,7 +88,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8488
)
8589
}
8690

87-
const { items } = await drainGooglePagedList<AnalyticsAccount, AccountsResponse>({
91+
const { items, truncated } = await drainGooglePagedList<AnalyticsAccount, AccountsResponse>({
8892
buildUrl: (pageToken) => {
8993
const url = new URL('https://analyticsadmin.googleapis.com/v1beta/accounts')
9094
url.searchParams.set('pageSize', String(ACCOUNT_PAGE_SIZE))
@@ -98,7 +102,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
98102
'Content-Type': 'application/json',
99103
},
100104
}),
101-
parseError: (response) => response.json().catch(() => ({})),
105+
parseError: async (response) => {
106+
try {
107+
return JSON.parse(
108+
await readResponseTextWithLimit(response, {
109+
maxBytes: MAX_ERROR_BODY_BYTES,
110+
label: 'Google Analytics accounts error',
111+
})
112+
)
113+
} catch {
114+
// An oversized or unparseable provider error must not be materialized into
115+
// the log and the response body; the status alone is still actionable.
116+
return { error: `Provider returned status ${response.status}` }
117+
}
118+
},
102119
getItems: (body) => body.accounts,
103120
getNextPageToken: (body) => body.nextPageToken,
104121
maxPages: MAX_ACCOUNT_PAGES,
@@ -109,7 +126,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
109126
account.name ? [{ name: account.name, displayName: account.displayName }] : []
110127
)
111128

112-
return NextResponse.json({ accounts })
129+
if (truncated) {
130+
logger.warn('Hit the Google Analytics pagination cap; the accounts picker is incomplete', {
131+
returned: accounts.length,
132+
})
133+
}
134+
135+
return NextResponse.json({ accounts, truncated })
113136
} catch (error) {
114137
if (error instanceof GooglePageError) {
115138
logger.error('Failed to fetch Google Analytics accounts', {

apps/sim/app/api/tools/google_analytics/properties/route.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { googleAnalyticsPropertiesSelectorContract } from '@/lib/api/contracts/s
44
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
55
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
66
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
89
import {
910
refreshAccessTokenIfNeeded,
@@ -16,6 +17,9 @@ const logger = createLogger('GoogleAnalyticsPropertiesAPI')
1617

1718
export const dynamic = 'force-dynamic'
1819

20+
/** Provider error bodies are echoed into logs and the response, so cap what we read. */
21+
const MAX_ERROR_BODY_BYTES = 32 * 1024
22+
1923
const MAX_ACCOUNT_SUMMARY_PAGES = 10
2024
const ACCOUNT_SUMMARY_PAGE_SIZE = 200
2125

@@ -86,7 +90,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8690
)
8791
}
8892

89-
const { items } = await drainGooglePagedList<AccountSummary, AccountSummariesResponse>({
93+
const { items, truncated } = await drainGooglePagedList<
94+
AccountSummary,
95+
AccountSummariesResponse
96+
>({
9097
buildUrl: (pageToken) => {
9198
const url = new URL('https://analyticsadmin.googleapis.com/v1beta/accountSummaries')
9299
url.searchParams.set('pageSize', String(ACCOUNT_SUMMARY_PAGE_SIZE))
@@ -100,7 +107,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
100107
'Content-Type': 'application/json',
101108
},
102109
}),
103-
parseError: (response) => response.json().catch(() => ({})),
110+
parseError: async (response) => {
111+
try {
112+
return JSON.parse(
113+
await readResponseTextWithLimit(response, {
114+
maxBytes: MAX_ERROR_BODY_BYTES,
115+
label: 'Google Analytics account summaries error',
116+
})
117+
)
118+
} catch {
119+
// An oversized or unparseable provider error must not be materialized into
120+
// the log and the response body; the status alone is still actionable.
121+
return { error: `Provider returned status ${response.status}` }
122+
}
123+
},
104124
getItems: (body) => body.accountSummaries,
105125
getNextPageToken: (body) => body.nextPageToken,
106126
maxPages: MAX_ACCOUNT_SUMMARY_PAGES,
@@ -121,7 +141,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
121141
)
122142
)
123143

124-
return NextResponse.json({ properties })
144+
if (truncated) {
145+
logger.warn('Hit the Google Analytics pagination cap; the properties picker is incomplete', {
146+
returned: properties.length,
147+
})
148+
}
149+
150+
return NextResponse.json({ properties, truncated })
125151
} catch (error) {
126152
if (error instanceof GooglePageError) {
127153
logger.error('Failed to fetch Google Analytics properties', {

apps/sim/blocks/blocks/google_analytics.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,28 +68,28 @@ export const GoogleAnalyticsBlock: BlockConfig = {
6868
run_report: [
6969
{ text: 'Report', field: 'metrics', core: true },
7070
{ text: 'by', field: 'dimensions' },
71-
{ text: 'for property', field: PROPERTY_FIELD },
71+
{ text: 'for property', field: PROPERTY_FIELD, core: true },
7272
{ text: 'from', field: 'startDate', core: true },
7373
{ text: 'to', field: 'endDate', core: true },
7474
],
7575
run_realtime_report: [
7676
{ text: 'Report realtime', field: 'metrics', core: true },
7777
{ text: 'by', field: 'dimensions' },
78-
{ text: 'for property', field: PROPERTY_FIELD },
78+
{ text: 'for property', field: PROPERTY_FIELD, core: true },
7979
],
8080
run_pivot_report: [
8181
{ text: 'Pivot', field: 'metrics', core: true },
8282
{ text: 'by', field: 'dimensions' },
83-
{ text: 'for property', field: PROPERTY_FIELD },
83+
{ text: 'for property', field: PROPERTY_FIELD, core: true },
8484
],
8585
check_compatibility: [
8686
{ text: 'Check compatibility of', field: 'metrics', core: true },
8787
{ text: 'with', field: 'dimensions' },
88-
{ text: 'on property', field: PROPERTY_FIELD },
88+
{ text: 'on property', field: PROPERTY_FIELD, core: true },
8989
],
9090
get_metadata: [
9191
'List available dimensions and metrics',
92-
{ text: 'for property', field: PROPERTY_FIELD },
92+
{ text: 'for property', field: PROPERTY_FIELD, core: true },
9393
],
9494
list_accounts: ['List all accessible Analytics accounts'],
9595
list_account_summaries: ['List all accounts and their properties'],

apps/sim/lib/api/contracts/selectors/google-analytics.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ export const googleAnalyticsPropertiesBodySchema = credentialWorkflowImpersonate
2727
export const googleAnalyticsAccountsSelectorContract = definePostSelector(
2828
'/api/tools/google_analytics/accounts',
2929
googleAnalyticsAccountsBodySchema,
30-
z.object({ accounts: z.array(googleAnalyticsAccountSchema) })
30+
z.object({ accounts: z.array(googleAnalyticsAccountSchema), truncated: z.boolean() })
3131
)
3232

3333
export const googleAnalyticsPropertiesSelectorContract = definePostSelector(
3434
'/api/tools/google_analytics/properties',
3535
googleAnalyticsPropertiesBodySchema,
36-
z.object({ properties: z.array(googleAnalyticsPropertySchema) })
36+
z.object({ properties: z.array(googleAnalyticsPropertySchema), truncated: z.boolean() })
3737
)
3838

3939
export type GoogleAnalyticsAccountsSelectorBody = ContractBodyInput<

apps/sim/tools/generated/tool-outputs.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

apps/sim/tools/google_analytics/get_metadata.ts

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {
22
extractGoogleApiError,
3+
type GoogleAnalyticsDimensionMetadata,
34
type GoogleAnalyticsGetMetadataParams,
45
type GoogleAnalyticsGetMetadataResponse,
5-
type GoogleAnalyticsMetadataField,
6+
type GoogleAnalyticsMetricMetadata,
67
normalizePropertyName,
78
toBooleanParam,
89
} from '@/tools/google_analytics/types'
@@ -12,27 +13,39 @@ interface RawMetadataField {
1213
apiName?: string
1314
uiName?: string
1415
description?: string
15-
type?: string
16-
dataType?: string
16+
category?: string
1717
customDefinition?: boolean
1818
deprecatedApiNames?: string[]
1919
}
2020

21-
/**
22-
* Normalizes a DimensionMetadata or MetricMetadata entry. Dimensions expose their
23-
* value type as `dataType`, metrics as `type`; both land on a single `type` field.
24-
*/
25-
function toMetadataField(raw: RawMetadataField): GoogleAnalyticsMetadataField {
21+
interface RawMetricMetadata extends RawMetadataField {
22+
type?: string
23+
expression?: string
24+
}
25+
26+
function toBaseField(raw: RawMetadataField) {
2627
return {
2728
apiName: raw.apiName ?? '',
2829
uiName: raw.uiName ?? null,
2930
description: raw.description ?? null,
30-
type: raw.type ?? raw.dataType ?? null,
31+
category: raw.category ?? null,
3132
customDefinition: raw.customDefinition ?? false,
3233
deprecatedApiNames: raw.deprecatedApiNames ?? [],
3334
}
3435
}
3536

37+
function toDimensionMetadata(raw: RawMetadataField): GoogleAnalyticsDimensionMetadata {
38+
return toBaseField(raw)
39+
}
40+
41+
function toMetricMetadata(raw: RawMetricMetadata): GoogleAnalyticsMetricMetadata {
42+
return {
43+
...toBaseField(raw),
44+
type: raw.type ?? null,
45+
expression: raw.expression ?? null,
46+
}
47+
}
48+
3649
export const googleAnalyticsGetMetadataTool: ToolConfig<
3750
GoogleAnalyticsGetMetadataParams,
3851
GoogleAnalyticsGetMetadataResponse
@@ -102,14 +115,14 @@ export const googleAnalyticsGetMetadataTool: ToolConfig<
102115
}
103116
}
104117

105-
let dimensions = (data.dimensions ?? []).map(toMetadataField)
106-
let metrics = (data.metrics ?? []).map(toMetadataField)
118+
let dimensions: GoogleAnalyticsDimensionMetadata[] = (data.dimensions ?? []).map(
119+
toDimensionMetadata
120+
)
121+
let metrics: GoogleAnalyticsMetricMetadata[] = (data.metrics ?? []).map(toMetricMetadata)
107122

108123
if (toBooleanParam(params?.customOnly)) {
109-
dimensions = dimensions.filter(
110-
(field: GoogleAnalyticsMetadataField) => field.customDefinition
111-
)
112-
metrics = metrics.filter((field: GoogleAnalyticsMetadataField) => field.customDefinition)
124+
dimensions = dimensions.filter((field) => field.customDefinition)
125+
metrics = metrics.filter((field) => field.customDefinition)
113126
}
114127

115128
return {
@@ -144,7 +157,11 @@ export const googleAnalyticsGetMetadataTool: ToolConfig<
144157
description: 'What the dimension measures',
145158
nullable: true,
146159
},
147-
type: { type: 'string', description: 'Value data type', nullable: true },
160+
category: {
161+
type: 'string',
162+
description: 'Grouping the dimension belongs to, e.g. Page / Screen',
163+
nullable: true,
164+
},
148165
customDefinition: {
149166
type: 'boolean',
150167
description: 'Whether this is a property-specific custom dimension',
@@ -167,7 +184,17 @@ export const googleAnalyticsGetMetadataTool: ToolConfig<
167184
apiName: { type: 'string', description: 'Name used in report requests' },
168185
uiName: { type: 'string', description: 'Name shown in the GA4 UI', nullable: true },
169186
description: { type: 'string', description: 'What the metric measures', nullable: true },
170-
type: { type: 'string', description: 'Value data type', nullable: true },
187+
type: { type: 'string', description: 'Metric value type', nullable: true },
188+
expression: {
189+
type: 'string',
190+
description: 'Formula for a derived metric',
191+
nullable: true,
192+
},
193+
category: {
194+
type: 'string',
195+
description: 'Grouping the metric belongs to, e.g. Session',
196+
nullable: true,
197+
},
171198
customDefinition: {
172199
type: 'boolean',
173200
description: 'Whether this is a property-specific custom metric',

apps/sim/tools/google_analytics/list_account_summaries.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type GoogleAnalyticsListAccountSummariesParams,
55
type GoogleAnalyticsListAccountSummariesResponse,
66
type GoogleAnalyticsPropertySummary,
7+
toOptionalNumberParam,
78
} from '@/tools/google_analytics/types'
89
import type { ToolConfig } from '@/tools/types'
910

@@ -46,7 +47,8 @@ export const googleAnalyticsListAccountSummariesTool: ToolConfig<
4647
request: {
4748
url: (params) => {
4849
const query = new URLSearchParams()
49-
if (params.pageSize !== undefined) query.set('pageSize', String(params.pageSize))
50+
const pageSize = toOptionalNumberParam(params.pageSize)
51+
if (pageSize !== undefined) query.set('pageSize', String(pageSize))
5052
if (params.pageToken) query.set('pageToken', params.pageToken)
5153
const suffix = query.toString()
5254
return `https://analyticsadmin.googleapis.com/v1beta/accountSummaries${suffix ? `?${suffix}` : ''}`

apps/sim/tools/google_analytics/list_accounts.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type GoogleAnalyticsListAccountsParams,
55
type GoogleAnalyticsListAccountsResponse,
66
toBooleanParam,
7+
toOptionalNumberParam,
78
} from '@/tools/google_analytics/types'
89
import type { ToolConfig } from '@/tools/types'
910

@@ -51,7 +52,8 @@ export const googleAnalyticsListAccountsTool: ToolConfig<
5152
request: {
5253
url: (params) => {
5354
const query = new URLSearchParams()
54-
if (params.pageSize !== undefined) query.set('pageSize', String(params.pageSize))
55+
const pageSize = toOptionalNumberParam(params.pageSize)
56+
if (pageSize !== undefined) query.set('pageSize', String(pageSize))
5557
if (params.pageToken) query.set('pageToken', params.pageToken)
5658
const showDeleted = toBooleanParam(params.showDeleted)
5759
if (showDeleted !== undefined) query.set('showDeleted', String(showDeleted))

apps/sim/tools/google_analytics/list_data_streams.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type GoogleAnalyticsListDataStreamsParams,
55
type GoogleAnalyticsListDataStreamsResponse,
66
normalizePropertyName,
7+
toOptionalNumberParam,
78
} from '@/tools/google_analytics/types'
89
import type { ToolConfig } from '@/tools/types'
910

@@ -86,7 +87,8 @@ export const googleAnalyticsListDataStreamsTool: ToolConfig<
8687
request: {
8788
url: (params) => {
8889
const query = new URLSearchParams()
89-
if (params.pageSize !== undefined) query.set('pageSize', String(params.pageSize))
90+
const pageSize = toOptionalNumberParam(params.pageSize)
91+
if (pageSize !== undefined) query.set('pageSize', String(pageSize))
9092
if (params.pageToken) query.set('pageToken', params.pageToken)
9193
const suffix = query.toString()
9294
return `https://analyticsadmin.googleapis.com/v1beta/${normalizePropertyName(params.propertyId)}/dataStreams${suffix ? `?${suffix}` : ''}`

0 commit comments

Comments
 (0)