Skip to content

Commit 0507acf

Browse files
authored
fix(amplitude): correct wire formats and add funnels/retention analytics (#5355)
* fix(amplitude): validate integration against API docs, add funnels/retention - Fix Identify/Group Identify sending JSON instead of the form-urlencoded body Amplitude requires - Fix Send Event using snake_case product_id/revenue_type instead of Amplitude's camelCase productId/revenueType - Fix Get Revenue parsing a response shape that never matched the real Revenue LTV API - Add EU data residency support across all tools - Add missing filters/formula/segment params to Event Segmentation, groupBy/segment to Active Users and Revenue - Add first_used/last_used to User Activity output, non_active/flow_hidden to List Events output - Add real-time/hourly interval options to Event Segmentation - Add Funnels and Retention tools for conversion and retention analysis - Harden JSON-shape validation across funnels/segmentation/retention (fail loudly on malformed or partial input instead of silently degrading) - Expose every tool output field (user_profile, send_event, user_search) on the block so nothing is unreachable downstream - Update brand colors to current Amplitude guide * fix(amplitude): validate retention brackets, require formula param, add segmentation 2nd group-by - Retention now validates retentionBrackets as a JSON array and requires it when retentionMode is "bracket", matching the block UI's requirement - Event Segmentation now throws if metric is "formula" but no formula is provided, matching the block UI's requirement - Event Segmentation now supports a documented second group-by property via groupBy2/g2 - Corrected Get Active Users' group-by copy — Amplitude's docs don't document a second-property syntax for /api/2/users the way they do for segmentation's g2, so the field no longer overpromises "max two"
1 parent 59d6b8a commit 0507acf

18 files changed

Lines changed: 1150 additions & 45 deletions

apps/sim/blocks/blocks/amplitude.ts

Lines changed: 437 additions & 6 deletions
Large diffs are not rendered by default.

apps/sim/tools/amplitude/event_segmentation.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
AmplitudeEventSegmentationParams,
33
AmplitudeEventSegmentationResponse,
44
} from '@/tools/amplitude/types'
5+
import { getDashboardHost } from '@/tools/amplitude/utils'
56
import type { ToolConfig } from '@/tools/types'
67

78
export const eventSegmentationTool: ToolConfig<
@@ -64,25 +65,81 @@ export const eventSegmentationTool: ToolConfig<
6465
visibility: 'user-or-llm',
6566
description: 'Property name to group by (prefix custom user properties with "gp:")',
6667
},
68+
groupBy2: {
69+
type: 'string',
70+
required: false,
71+
visibility: 'user-or-llm',
72+
description: 'Second property name to group by (prefix custom user properties with "gp:")',
73+
},
6774
limit: {
6875
type: 'string',
6976
required: false,
7077
visibility: 'user-or-llm',
7178
description: 'Maximum number of group-by values (max 1000)',
7279
},
80+
filters: {
81+
type: 'string',
82+
required: false,
83+
visibility: 'user-or-llm',
84+
description:
85+
'JSON array of filter objects applied to the event, e.g. [{"subprop_type":"event","subprop_key":"city","subprop_op":"is","subprop_value":["San Francisco"]}]',
86+
},
87+
formula: {
88+
type: 'string',
89+
required: false,
90+
visibility: 'user-or-llm',
91+
description: 'Required when metric is "formula", e.g. "UNIQUES(A)/UNIQUES(B)"',
92+
},
93+
segment: {
94+
type: 'string',
95+
required: false,
96+
visibility: 'user-or-llm',
97+
description: 'JSON segment definition(s) applied to the query',
98+
},
99+
dataResidency: {
100+
type: 'string',
101+
required: false,
102+
visibility: 'user-or-llm',
103+
description: 'Data residency region: "us" (default) or "eu"',
104+
},
73105
},
74106

75107
request: {
76108
url: (params) => {
77-
const url = new URL('https://amplitude.com/api/2/events/segmentation')
78-
const eventObj = JSON.stringify({ event_type: params.eventType })
79-
url.searchParams.set('e', eventObj)
109+
const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/events/segmentation`)
110+
const event: Record<string, unknown> = { event_type: params.eventType }
111+
112+
if (params.filters) {
113+
let parsedFilters: unknown
114+
try {
115+
parsedFilters = JSON.parse(params.filters)
116+
} catch {
117+
parsedFilters = undefined
118+
}
119+
if (!Array.isArray(parsedFilters)) {
120+
throw new Error(
121+
'Amplitude Event Segmentation: "filters" must be a valid JSON array of filter objects'
122+
)
123+
}
124+
event.filters = parsedFilters
125+
}
126+
127+
if (params.metric === 'formula' && !params.formula) {
128+
throw new Error(
129+
'Amplitude Event Segmentation: "formula" is required when metric is "formula"'
130+
)
131+
}
132+
133+
url.searchParams.set('e', JSON.stringify(event))
80134
url.searchParams.set('start', params.start)
81135
url.searchParams.set('end', params.end)
82136
if (params.metric) url.searchParams.set('m', params.metric)
83137
if (params.interval) url.searchParams.set('i', params.interval)
84138
if (params.groupBy) url.searchParams.set('g', params.groupBy)
139+
if (params.groupBy2) url.searchParams.set('g2', params.groupBy2)
85140
if (params.limit) url.searchParams.set('limit', params.limit)
141+
if (params.formula) url.searchParams.set('formula', params.formula)
142+
if (params.segment) url.searchParams.set('s', params.segment)
86143
return url.toString()
87144
},
88145
method: 'GET',
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import type { AmplitudeFunnelsParams, AmplitudeFunnelsResponse } from '@/tools/amplitude/types'
2+
import { getDashboardHost } from '@/tools/amplitude/utils'
3+
import type { ToolConfig } from '@/tools/types'
4+
5+
export const funnelsTool: ToolConfig<AmplitudeFunnelsParams, AmplitudeFunnelsResponse> = {
6+
id: 'amplitude_funnels',
7+
name: 'Amplitude Funnels',
8+
description: 'Analyze conversion rates and drop-off between a sequence of events.',
9+
version: '1.0.0',
10+
11+
params: {
12+
apiKey: {
13+
type: 'string',
14+
required: true,
15+
visibility: 'user-only',
16+
description: 'Amplitude API Key',
17+
},
18+
secretKey: {
19+
type: 'string',
20+
required: true,
21+
visibility: 'user-only',
22+
description: 'Amplitude Secret Key',
23+
},
24+
events: {
25+
type: 'string',
26+
required: true,
27+
visibility: 'user-or-llm',
28+
description:
29+
'JSON array of event objects, one per funnel step in order, e.g. [{"event_type":"signup"},{"event_type":"purchase"}]',
30+
},
31+
start: {
32+
type: 'string',
33+
required: true,
34+
visibility: 'user-or-llm',
35+
description: 'Start date in YYYYMMDD format',
36+
},
37+
end: {
38+
type: 'string',
39+
required: true,
40+
visibility: 'user-or-llm',
41+
description: 'End date in YYYYMMDD format',
42+
},
43+
mode: {
44+
type: 'string',
45+
required: false,
46+
visibility: 'user-or-llm',
47+
description: 'Funnel ordering: "ordered", "unordered", or "sequential" (default: ordered)',
48+
},
49+
userType: {
50+
type: 'string',
51+
required: false,
52+
visibility: 'user-or-llm',
53+
description: 'User type: "new" or "active" (default: active)',
54+
},
55+
interval: {
56+
type: 'string',
57+
required: false,
58+
visibility: 'user-or-llm',
59+
description:
60+
'Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)',
61+
},
62+
conversionWindowSeconds: {
63+
type: 'string',
64+
required: false,
65+
visibility: 'user-or-llm',
66+
description: 'Conversion window in seconds (default: 2592000, i.e. 30 days)',
67+
},
68+
groupBy: {
69+
type: 'string',
70+
required: false,
71+
visibility: 'user-or-llm',
72+
description: 'Property to group by (limit: one; prefix custom properties with "gp:")',
73+
},
74+
limit: {
75+
type: 'string',
76+
required: false,
77+
visibility: 'user-or-llm',
78+
description: 'Maximum number of group-by values (default: 100, max: 1000)',
79+
},
80+
segment: {
81+
type: 'string',
82+
required: false,
83+
visibility: 'user-or-llm',
84+
description: 'JSON segment definition(s) applied to the query',
85+
},
86+
dataResidency: {
87+
type: 'string',
88+
required: false,
89+
visibility: 'user-or-llm',
90+
description: 'Data residency region: "us" (default) or "eu"',
91+
},
92+
},
93+
94+
request: {
95+
url: (params) => {
96+
const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/funnels`)
97+
let parsed: unknown
98+
try {
99+
parsed = JSON.parse(params.events)
100+
} catch {
101+
throw new Error('Amplitude Funnels: "events" must be a valid JSON array of event objects')
102+
}
103+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
104+
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
105+
106+
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isPlainObject)) {
107+
throw new Error(
108+
'Amplitude Funnels: "events" must be a non-empty JSON array of event objects'
109+
)
110+
}
111+
for (const step of parsed) {
112+
url.searchParams.append('e', JSON.stringify(step))
113+
}
114+
url.searchParams.set('start', params.start)
115+
url.searchParams.set('end', params.end)
116+
if (params.mode) url.searchParams.set('mode', params.mode)
117+
if (params.userType) url.searchParams.set('n', params.userType)
118+
if (params.interval) url.searchParams.set('i', params.interval)
119+
if (params.conversionWindowSeconds) url.searchParams.set('cs', params.conversionWindowSeconds)
120+
if (params.groupBy) url.searchParams.set('g', params.groupBy)
121+
if (params.limit) url.searchParams.set('limit', params.limit)
122+
if (params.segment) url.searchParams.set('s', params.segment)
123+
return url.toString()
124+
},
125+
method: 'GET',
126+
headers: (params) => ({
127+
Authorization: `Basic ${btoa(`${params.apiKey}:${params.secretKey}`)}`,
128+
}),
129+
},
130+
131+
transformResponse: async (response: Response) => {
132+
const data = await response.json()
133+
134+
if (!response.ok) {
135+
throw new Error(data.error || `Amplitude Funnels API error: ${response.status}`)
136+
}
137+
138+
const results = (Array.isArray(data.data) ? data.data : []) as Array<Record<string, unknown>>
139+
140+
const funnels = results.map((r) => {
141+
const dayFunnels = r.dayFunnels as Record<string, unknown> | undefined
142+
return {
143+
stepByStep: (r.stepByStep as number[]) ?? [],
144+
cumulative: (r.cumulative as number[]) ?? [],
145+
cumulativeRaw: (r.cumulativeRaw as number[]) ?? [],
146+
medianTransTimes: (r.medianTransTimes as number[]) ?? [],
147+
avgTransTimes: (r.avgTransTimes as number[]) ?? [],
148+
events: (r.events as string[]) ?? [],
149+
dayFunnels: dayFunnels
150+
? {
151+
series: (dayFunnels.series as number[][]) ?? [],
152+
xValues: (dayFunnels.xValues as string[]) ?? [],
153+
}
154+
: null,
155+
}
156+
})
157+
158+
return {
159+
success: true,
160+
output: { funnels },
161+
}
162+
},
163+
164+
outputs: {
165+
funnels: {
166+
type: 'array',
167+
description: 'Funnel results, one entry per segment',
168+
items: {
169+
type: 'object',
170+
properties: {
171+
stepByStep: { type: 'json', description: 'Conversion count at each step' },
172+
cumulative: {
173+
type: 'json',
174+
description: 'Cumulative conversion percentage at each step',
175+
},
176+
cumulativeRaw: { type: 'json', description: 'Cumulative conversion count at each step' },
177+
medianTransTimes: {
178+
type: 'json',
179+
description: 'Median transition time between steps (ms)',
180+
},
181+
avgTransTimes: {
182+
type: 'json',
183+
description: 'Average transition time between steps (ms)',
184+
},
185+
events: { type: 'json', description: 'Event names for each funnel step' },
186+
dayFunnels: {
187+
type: 'json',
188+
description: 'Daily funnel breakdown {series, xValues}',
189+
optional: true,
190+
},
191+
},
192+
},
193+
},
194+
},
195+
}

apps/sim/tools/amplitude/get_active_users.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
AmplitudeGetActiveUsersParams,
33
AmplitudeGetActiveUsersResponse,
44
} from '@/tools/amplitude/types'
5+
import { getDashboardHost } from '@/tools/amplitude/utils'
56
import type { ToolConfig } from '@/tools/types'
67

78
export const getActiveUsersTool: ToolConfig<
@@ -50,15 +51,35 @@ export const getActiveUsersTool: ToolConfig<
5051
visibility: 'user-or-llm',
5152
description: 'Time interval: 1 (daily), 7 (weekly), or 30 (monthly)',
5253
},
54+
groupBy: {
55+
type: 'string',
56+
required: false,
57+
visibility: 'user-or-llm',
58+
description: 'Property name to group by',
59+
},
60+
segment: {
61+
type: 'string',
62+
required: false,
63+
visibility: 'user-or-llm',
64+
description: 'JSON segment definition(s) applied to the query',
65+
},
66+
dataResidency: {
67+
type: 'string',
68+
required: false,
69+
visibility: 'user-or-llm',
70+
description: 'Data residency region: "us" (default) or "eu"',
71+
},
5372
},
5473

5574
request: {
5675
url: (params) => {
57-
const url = new URL('https://amplitude.com/api/2/users')
76+
const url = new URL(`${getDashboardHost(params.dataResidency)}/api/2/users`)
5877
url.searchParams.set('start', params.start)
5978
url.searchParams.set('end', params.end)
6079
if (params.metric) url.searchParams.set('m', params.metric)
6180
if (params.interval) url.searchParams.set('i', params.interval)
81+
if (params.groupBy) url.searchParams.set('g', params.groupBy)
82+
if (params.segment) url.searchParams.set('s', params.segment)
6283
return url.toString()
6384
},
6485
method: 'GET',

0 commit comments

Comments
 (0)