Skip to content

Commit a48ecd2

Browse files
authored
fix(posthog): validate integration against live API docs, add self-hosted support + CRUD coverage (#5476)
* fix(posthog): validate integration against live API docs, add self-hosted support + CRUD coverage Fix missing response.ok checks across ~36 tools that silently treated error bodies as success. Fix delete_feature_flag ignoring failure responses, evaluate_flags targeting the undocumented /decide endpoint without the required api_key body field, and batch_events reporting a hardcoded events_processed count. Add self-hosted host support (utils.ts) alongside the existing US/EU region selector, and complete CRUD coverage with 5 new tools: update_insight, update_cohort, update_experiment, delete_survey, create_dashboard. * fix(posthog): remove dead response.ok checks, fix real schema/endpoint bugs The prior commit added if (!response.ok) branches inside transformResponse across ~40 tool files. This is dead code — tools/index.ts already throws on non-2xx (and error-payload) responses before transformResponse is ever invoked, so transformResponse only ever receives already-successful responses. Reverted to the idiomatic no-error-branch pattern used elsewhere in the codebase (e.g. hunter/email_verifier.ts), and added a posthog-errors errorExtractor so the framework's own error throw surfaces PostHog's real {type, code, detail, attr} error shape instead of a generic status message. Verified against PostHog's live OpenAPI schema, fixed real bugs: - delete_feature_flag: DELETE always returns 405 (hard delete not allowed); switched to PATCH with deleted: true - delete_person: no single-person DELETE endpoint exists; switched to POST .../persons/bulk_delete/ with ids: [personId] - create_annotation: insight_short_id is read-only on create; the writable field is dashboard_id - removed nonexistent fields (experiment variants, insight filters/saved) not present in the current schema - added missing trailing slashes on feature-flag/experiment URLs * fix(posthog): reject unsafe self-hosted host values (SSRF) The self-hosted host field accepted any string and only stripped trailing slashes before prepending https://, so a workflow could point it at loopback/private/link-local addresses (e.g. cloud instance- metadata endpoints) and the executor would make a real server-side request to it. Reuse the shared validateExternalUrl SSRF guard, same pattern already used for Convex's custom deployment URL (tools/convex/utils.ts); the tool executor separately re-validates with DNS resolution and pins the resolved IP for the actual request. Also drop an unused params arg in evaluate_flags' headers function. * fix(posthog): fail loudly instead of silently dropping data on bad JSON params Several tools caught JSON.parse failures on user-supplied filter/query/ parameters strings and silently substituted {} or null, which would wipe the corresponding field on the PATCH/create request instead of surfacing an error (Cursor Bugbot flagged this for update_cohort, update_experiment, update_insight; the same pattern existed in their create_* / update_feature_flag / evaluate_flags counterparts, fixed for consistency). Now throws a descriptive error, matching the existing convention in tools/notion/query_database.ts. Also fixes batch_events: the request body silently sent an empty batch on invalid JSON, and transformResponse always reported "captured successfully" even when PostHog's response indicated failure (data.status !== 1). * fix(posthog): mark region required for update_experiment posthog_update_experiment was the only experiment operation missing from the region field's required-condition list; the other three (create/get/list) already require it. Region is unconditionally visible on this block (no condition key gates it), so this was a required-ness inconsistency rather than a functional bug, but users updating an EU experiment should still be prompted to select the region explicitly rather than relying on the default. * fix(posthog): fix cross-field value leakage and false-success reports Merges update_insight's query subblock into the same insightQuery id already used by create_insight (Cursor flagged: it previously reused the 'query' subblock id shared by posthog_query's HogQL field and the cohort query fields, so switching operations could carry a stale HogQL/cohort-JSON value into an insight PATCH). Matches the existing merged-condition pattern used elsewhere in this block instead of duplicating the subblock id. Also fixes two more false-success reports in the same class as the batch_events/delete_feature_flag fixes: capture_event now checks the ingest response's status field (same {"status": 1} contract as /batch/) instead of unconditionally returning success, and delete_person now reports failure when persons_deleted is 0 instead of always success: true.
1 parent 954fdd8 commit a48ecd2

54 files changed

Lines changed: 1792 additions & 384 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/blocks/blocks/posthog.ts

Lines changed: 192 additions & 23 deletions
Large diffs are not rendered by default.

apps/sim/tools/error-extractors.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,17 @@ const ERROR_EXTRACTORS: ErrorExtractorConfig[] = [
243243
return undefined
244244
},
245245
},
246+
{
247+
id: 'posthog-errors',
248+
description: 'PostHog API error format with type/code/detail/attr fields',
249+
examples: ['PostHog API'],
250+
extract: (errorInfo) => {
251+
const detail = errorInfo?.data?.detail
252+
if (typeof detail !== 'string' || !detail.trim()) return undefined
253+
const attr = errorInfo?.data?.attr
254+
return typeof attr === 'string' && attr ? `${detail} (${attr})` : detail
255+
},
256+
},
246257
{
247258
id: 'plain-text-data',
248259
description: 'Plain text error response',
@@ -320,6 +331,7 @@ export const ErrorExtractorId = {
320331
SOAP_FAULT: 'soap-fault',
321332
OAUTH_ERROR_DESCRIPTION: 'oauth-error-description',
322333
NESTED_ERROR_OBJECT: 'nested-error-object',
334+
POSTHOG_ERRORS: 'posthog-errors',
323335
PLAIN_TEXT_DATA: 'plain-text-data',
324336
HTTP_STATUS_TEXT: 'http-status-text',
325337
} as const

apps/sim/tools/posthog/batch_events.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { getErrorMessage } from '@sim/utils/errors'
2+
import { getPostHogIngestBaseUrl } from '@/tools/posthog/utils'
13
import type { ToolConfig } from '@/tools/types'
24

35
export interface PostHogBatchEventsParams {
46
projectApiKey: string
57
region?: 'us' | 'eu'
8+
host?: string
69
batch: string
710
}
811

@@ -35,6 +38,13 @@ export const batchEventsTool: ToolConfig<PostHogBatchEventsParams, PostHogBatchE
3538
description: 'PostHog region: us (default) or eu',
3639
default: 'us',
3740
},
41+
host: {
42+
type: 'string',
43+
required: false,
44+
visibility: 'user-only',
45+
description:
46+
'Self-hosted PostHog instance host (e.g., "posthog.mycompany.com"). Overrides the region setting when provided.',
47+
},
3848
batch: {
3949
type: 'string',
4050
required: true,
@@ -46,49 +56,44 @@ export const batchEventsTool: ToolConfig<PostHogBatchEventsParams, PostHogBatchE
4656

4757
request: {
4858
url: (params) => {
49-
const baseUrl =
50-
params.region === 'eu' ? 'https://eu.i.posthog.com' : 'https://us.i.posthog.com'
59+
const baseUrl = getPostHogIngestBaseUrl(params.region, params.host)
5160
return `${baseUrl}/batch/`
5261
},
5362
method: 'POST',
5463
headers: (params) => ({
5564
'Content-Type': 'application/json',
5665
}),
5766
body: (params) => {
58-
let batch: any[]
67+
let batch: unknown
5968
try {
6069
batch = JSON.parse(params.batch)
61-
} catch (e) {
62-
batch = []
70+
} catch (error) {
71+
throw new Error(`Invalid batch JSON: ${getErrorMessage(error)}`)
72+
}
73+
if (!Array.isArray(batch)) {
74+
throw new Error('batch must be a JSON array of events')
6375
}
6476

6577
return {
6678
api_key: params.projectApiKey,
67-
batch: batch,
79+
batch,
6880
}
6981
},
7082
},
7183

72-
transformResponse: async (response: Response) => {
73-
if (response.ok) {
74-
const data = await response.json()
75-
return {
76-
success: true,
77-
output: {
78-
status: 'Batch events captured successfully',
79-
events_processed: data.status === 1 ? JSON.parse(data.batch || '[]').length : 0,
80-
},
81-
}
82-
}
84+
transformResponse: async (response: Response, params) => {
85+
const data = await response.json()
86+
const eventsProcessed = params ? (JSON.parse(params.batch) as unknown[]).length : 0
87+
const success = data.status === 1
8388

84-
const error = await response.text()
8589
return {
86-
success: false,
90+
success,
8791
output: {
88-
status: 'Failed to capture batch events',
89-
events_processed: 0,
92+
status: success
93+
? 'Batch events captured successfully'
94+
: `Batch events capture failed (status: ${data.status})`,
95+
events_processed: success ? eventsProcessed : 0,
9096
},
91-
error: error || 'Unknown error occurred',
9297
}
9398
},
9499

apps/sim/tools/posthog/capture_event.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { getErrorMessage } from '@sim/utils/errors'
2+
import { getPostHogIngestBaseUrl } from '@/tools/posthog/utils'
13
import type { ToolConfig } from '@/tools/types'
24

35
export interface PostHogCaptureEventParams {
46
projectApiKey: string
57
region?: 'us' | 'eu'
8+
host?: string
69
event: string
710
distinctId: string
811
properties?: string
@@ -38,6 +41,13 @@ export const captureEventTool: ToolConfig<PostHogCaptureEventParams, PostHogCapt
3841
description: 'PostHog region: us (default) or eu',
3942
default: 'us',
4043
},
44+
host: {
45+
type: 'string',
46+
required: false,
47+
visibility: 'user-only',
48+
description:
49+
'Self-hosted PostHog instance host (e.g., "posthog.mycompany.com"). Overrides the region setting when provided.',
50+
},
4151
event: {
4252
type: 'string',
4353
required: true,
@@ -69,8 +79,7 @@ export const captureEventTool: ToolConfig<PostHogCaptureEventParams, PostHogCapt
6979

7080
request: {
7181
url: (params) => {
72-
const baseUrl =
73-
params.region === 'eu' ? 'https://eu.i.posthog.com' : 'https://us.i.posthog.com'
82+
const baseUrl = getPostHogIngestBaseUrl(params.region, params.host)
7483
return `${baseUrl}/capture/`
7584
},
7685
method: 'POST',
@@ -87,8 +96,8 @@ export const captureEventTool: ToolConfig<PostHogCaptureEventParams, PostHogCapt
8796
if (params.properties) {
8897
try {
8998
body.properties = JSON.parse(params.properties)
90-
} catch (e) {
91-
body.properties = {}
99+
} catch (error) {
100+
throw new Error(`Invalid properties JSON: ${getErrorMessage(error)}`)
92101
}
93102
}
94103

@@ -101,22 +110,16 @@ export const captureEventTool: ToolConfig<PostHogCaptureEventParams, PostHogCapt
101110
},
102111

103112
transformResponse: async (response: Response) => {
104-
if (response.ok) {
105-
return {
106-
success: true,
107-
output: {
108-
status: 'Event captured successfully',
109-
},
110-
}
111-
}
113+
const data = await response.json()
114+
const success = data.status === 1
112115

113-
const error = await response.text()
114116
return {
115-
success: false,
117+
success,
116118
output: {
117-
status: 'Failed to capture event',
119+
status: success
120+
? 'Event captured successfully'
121+
: `Event capture failed (status: ${data.status})`,
118122
},
119-
error: error || 'Unknown error occurred',
120123
}
121124
},
122125

apps/sim/tools/posthog/create_annotation.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1+
import { getPostHogAppBaseUrl } from '@/tools/posthog/utils'
12
import type { ToolConfig } from '@/tools/types'
23

34
interface PostHogCreateAnnotationParams {
45
apiKey: string
56
projectId: string
67
region: string
8+
host?: string
79
content: string
810
date_marker: string
911
scope?: string
1012
dashboard_item?: string
11-
insight_short_id?: string
13+
dashboard_id?: string
1214
}
1315

1416
interface PostHogCreateAnnotationResponse {
@@ -21,6 +23,7 @@ interface PostHogCreateAnnotationResponse {
2123
updated_at: string
2224
created_by: Record<string, any> | null
2325
dashboard_item: number | null
26+
dashboard_id: number | null
2427
insight_short_id: string | null
2528
insight_name: string | null
2629
scope: string
@@ -37,6 +40,7 @@ export const createAnnotationTool: ToolConfig<
3740
description:
3841
'Create a new annotation in PostHog. Mark important events on your graphs with date and description.',
3942
version: '1.0.0',
43+
errorExtractor: 'posthog-errors',
4044

4145
params: {
4246
apiKey: {
@@ -58,6 +62,13 @@ export const createAnnotationTool: ToolConfig<
5862
description: 'PostHog cloud region: "us" or "eu" (default: "us")',
5963
default: 'us',
6064
},
65+
host: {
66+
type: 'string',
67+
required: false,
68+
visibility: 'user-only',
69+
description:
70+
'Self-hosted PostHog instance host (e.g., "posthog.mycompany.com"). Overrides the region setting when provided.',
71+
},
6172
content: {
6273
type: 'string',
6374
required: true,
@@ -75,25 +86,28 @@ export const createAnnotationTool: ToolConfig<
7586
type: 'string',
7687
required: false,
7788
visibility: 'user-or-llm',
78-
description: 'Scope of the annotation: "project" or "dashboard_item" (default: "project")',
89+
description:
90+
'Scope of the annotation: "project", "organization", "dashboard", or "dashboard_item" (default: "project")',
7991
},
8092
dashboard_item: {
8193
type: 'string',
8294
required: false,
8395
visibility: 'user-or-llm',
84-
description: 'ID of dashboard item to attach this annotation to',
96+
description:
97+
'ID of the dashboard tile (insight) to attach this annotation to (used when scope is "dashboard_item")',
8598
},
86-
insight_short_id: {
99+
dashboard_id: {
87100
type: 'string',
88101
required: false,
89102
visibility: 'user-or-llm',
90-
description: 'Short ID of the insight to attach this annotation to',
103+
description:
104+
'ID of the dashboard to attach this annotation to (used when scope is "dashboard")',
91105
},
92106
},
93107

94108
request: {
95109
url: (params) => {
96-
const baseUrl = params.region === 'eu' ? 'https://eu.posthog.com' : 'https://us.posthog.com'
110+
const baseUrl = getPostHogAppBaseUrl(params.region as 'us' | 'eu' | undefined, params.host)
97111
return `${baseUrl}/api/projects/${params.projectId}/annotations/`
98112
},
99113
method: 'POST',
@@ -115,8 +129,8 @@ export const createAnnotationTool: ToolConfig<
115129
body.dashboard_item = Number(params.dashboard_item)
116130
}
117131

118-
if (params.insight_short_id) {
119-
body.insight_short_id = params.insight_short_id
132+
if (params.dashboard_id) {
133+
body.dashboard_id = Number(params.dashboard_id)
120134
}
121135

122136
return body
@@ -136,6 +150,7 @@ export const createAnnotationTool: ToolConfig<
136150
updated_at: data.updated_at,
137151
created_by: data.created_by || null,
138152
dashboard_item: data.dashboard_item || null,
153+
dashboard_id: data.dashboard_id || null,
139154
insight_short_id: data.insight_short_id || null,
140155
insight_name: data.insight_name || null,
141156
scope: data.scope || '',
@@ -175,6 +190,11 @@ export const createAnnotationTool: ToolConfig<
175190
description: 'ID of dashboard item this annotation is attached to',
176191
optional: true,
177192
},
193+
dashboard_id: {
194+
type: 'number',
195+
description: 'ID of the dashboard this annotation is attached to',
196+
optional: true,
197+
},
178198
insight_short_id: {
179199
type: 'string',
180200
description: 'Short ID of the insight this annotation is attached to',
@@ -187,7 +207,7 @@ export const createAnnotationTool: ToolConfig<
187207
},
188208
scope: {
189209
type: 'string',
190-
description: 'Scope of the annotation (project or dashboard_item)',
210+
description: 'Scope of the annotation (project, organization, dashboard, or dashboard_item)',
191211
},
192212
deleted: {
193213
type: 'boolean',

apps/sim/tools/posthog/create_cohort.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { getPostHogAppBaseUrl } from '@/tools/posthog/utils'
12
import type { ToolConfig } from '@/tools/types'
23

34
interface PostHogCreateCohortParams {
45
apiKey: string
56
projectId: string
67
region: string
8+
host?: string
79
name: string
810
description?: string
911
filters?: string
@@ -38,6 +40,7 @@ export const createCohortTool: ToolConfig<PostHogCreateCohortParams, PostHogCrea
3840
description:
3941
'Create a new cohort in PostHog. Requires cohort name and filter or query configuration.',
4042
version: '1.0.0',
43+
errorExtractor: 'posthog-errors',
4144

4245
params: {
4346
apiKey: {
@@ -59,6 +62,13 @@ export const createCohortTool: ToolConfig<PostHogCreateCohortParams, PostHogCrea
5962
description: 'PostHog cloud region: "us" or "eu" (default: "us")',
6063
default: 'us',
6164
},
65+
host: {
66+
type: 'string',
67+
required: false,
68+
visibility: 'user-only',
69+
description:
70+
'Self-hosted PostHog instance host (e.g., "posthog.mycompany.com"). Overrides the region setting when provided.',
71+
},
6272
name: {
6373
type: 'string',
6474
required: false,
@@ -101,7 +111,7 @@ export const createCohortTool: ToolConfig<PostHogCreateCohortParams, PostHogCrea
101111

102112
request: {
103113
url: (params) => {
104-
const baseUrl = params.region === 'eu' ? 'https://eu.posthog.com' : 'https://us.posthog.com'
114+
const baseUrl = getPostHogAppBaseUrl(params.region as 'us' | 'eu' | undefined, params.host)
105115
return `${baseUrl}/api/projects/${params.projectId}/cohorts/`
106116
},
107117
method: 'POST',

0 commit comments

Comments
 (0)