@mantajs/plugin-posthog-proxy 0.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +2 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/modules/posthog/api/[...path]/route.d.ts +6 -0
  6. package/dist/modules/posthog/api/[...path]/route.d.ts.map +1 -0
  7. package/dist/modules/posthog/api/[...path]/route.js +625 -0
  8. package/dist/modules/posthog/api/[...path]/route.js.map +1 -0
  9. package/dist/modules/posthog/commands/identify-user.d.ts +12 -0
  10. package/dist/modules/posthog/commands/identify-user.d.ts.map +1 -0
  11. package/dist/modules/posthog/commands/identify-user.js +31 -0
  12. package/dist/modules/posthog/commands/identify-user.js.map +1 -0
  13. package/dist/modules/posthog/commands/track-event.d.ts +15 -0
  14. package/dist/modules/posthog/commands/track-event.d.ts.map +1 -0
  15. package/dist/modules/posthog/commands/track-event.js +33 -0
  16. package/dist/modules/posthog/commands/track-event.js.map +1 -0
  17. package/dist/modules/posthog/entities/event/model.d.ts +3 -0
  18. package/dist/modules/posthog/entities/event/model.d.ts.map +1 -0
  19. package/dist/modules/posthog/entities/event/model.js +4 -0
  20. package/dist/modules/posthog/entities/event/model.js.map +1 -0
  21. package/dist/modules/posthog/entities/insight/model.d.ts +3 -0
  22. package/dist/modules/posthog/entities/insight/model.d.ts.map +1 -0
  23. package/dist/modules/posthog/entities/insight/model.js +4 -0
  24. package/dist/modules/posthog/entities/insight/model.js.map +1 -0
  25. package/dist/modules/posthog/entities/person/model.d.ts +3 -0
  26. package/dist/modules/posthog/entities/person/model.d.ts.map +1 -0
  27. package/dist/modules/posthog/entities/person/model.js +4 -0
  28. package/dist/modules/posthog/entities/person/model.js.map +1 -0
  29. package/dist/modules/posthog/queries/graph.d.ts +3 -0
  30. package/dist/modules/posthog/queries/graph.d.ts.map +1 -0
  31. package/dist/modules/posthog/queries/graph.js +23 -0
  32. package/dist/modules/posthog/queries/graph.js.map +1 -0
  33. package/dist/modules/posthog/queries/lib/execute.d.ts +26 -0
  34. package/dist/modules/posthog/queries/lib/execute.d.ts.map +1 -0
  35. package/dist/modules/posthog/queries/lib/execute.js +93 -0
  36. package/dist/modules/posthog/queries/lib/execute.js.map +1 -0
  37. package/dist/modules/posthog/queries/lib/schema.d.ts +13 -0
  38. package/dist/modules/posthog/queries/lib/schema.d.ts.map +1 -0
  39. package/dist/modules/posthog/queries/lib/schema.js +42 -0
  40. package/dist/modules/posthog/queries/lib/schema.js.map +1 -0
  41. package/dist/modules/posthog/queries/lib/translate.d.ts +15 -0
  42. package/dist/modules/posthog/queries/lib/translate.d.ts.map +1 -0
  43. package/dist/modules/posthog/queries/lib/translate.js +72 -0
  44. package/dist/modules/posthog/queries/lib/translate.js.map +1 -0
  45. package/dist/modules/posthog/schemas.d.ts +103 -0
  46. package/dist/modules/posthog/schemas.d.ts.map +1 -0
  47. package/dist/modules/posthog/schemas.js +42 -0
  48. package/dist/modules/posthog/schemas.js.map +1 -0
  49. package/package.json +37 -0
  50. package/src/index.ts +1 -0
  51. package/src/modules/posthog/api/[...path]/route.ts +672 -0
  52. package/src/modules/posthog/commands/identify-user.ts +32 -0
  53. package/src/modules/posthog/commands/track-event.ts +34 -0
  54. package/src/modules/posthog/entities/event/model.ts +4 -0
  55. package/src/modules/posthog/entities/insight/model.ts +4 -0
  56. package/src/modules/posthog/entities/person/model.ts +4 -0
  57. package/src/modules/posthog/queries/graph.ts +24 -0
  58. package/src/modules/posthog/queries/lib/execute.ts +111 -0
  59. package/src/modules/posthog/queries/lib/schema.ts +45 -0
  60. package/src/modules/posthog/queries/lib/translate.ts +73 -0
  61. package/src/modules/posthog/schemas.ts +48 -0
@@ -0,0 +1,32 @@
1
+ import { defineCommand } from '@mantajs/core'
2
+ import { PostHog } from 'posthog-node'
3
+ import { postHogIdentifyInputSchema } from '../schemas'
4
+
5
+ export default defineCommand({
6
+ name: 'posthog:identify-user',
7
+ description:
8
+ 'Identify an anonymous PostHog visitor with their properties. Links all past anonymous events to the known user.',
9
+ input: postHogIdentifyInputSchema,
10
+ async workflow(input, { step }) {
11
+ // step.action signature: (name, { invoke, compensate }) → returns a runner function
12
+ // that you must call with (input, ctx) to execute. Compensate is mandatory but a no-op
13
+ // here: identify writes are idempotent and there's no PostHog primitive to "un-identify".
14
+ return step.action('posthog-identify', {
15
+ invoke: async () => {
16
+ const token = process.env.POSTHOG_TOKEN
17
+ if (!token) return { success: false, error: 'POSTHOG_TOKEN env var not set' }
18
+
19
+ const posthog = new PostHog(token, { host: process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com' })
20
+ posthog.identify({
21
+ distinctId: input.distinctId,
22
+ properties: input.properties,
23
+ })
24
+ await posthog.shutdown()
25
+ return { success: true }
26
+ },
27
+ compensate: async () => {
28
+ // No-op — identify writes are idempotent.
29
+ },
30
+ })(input)
31
+ },
32
+ })
@@ -0,0 +1,34 @@
1
+ import { defineCommand } from '@mantajs/core'
2
+ import { PostHog } from 'posthog-node'
3
+ import { postHogCaptureInputSchema } from '../schemas'
4
+
5
+ export default defineCommand({
6
+ name: 'posthog:track-event',
7
+ description:
8
+ 'Track a custom analytics event in PostHog (page view, click, purchase, sign-up, etc.). Use for sending server-side events.',
9
+ input: postHogCaptureInputSchema,
10
+ async workflow(input, { step }) {
11
+ // step.action signature: (name, { invoke, compensate }) → returns a runner function
12
+ // that you must call with (input, ctx) to execute. Compensate is mandatory but a no-op
13
+ // here: PostHog events are immutable once sent, so there's nothing to rollback if a
14
+ // later step in the workflow fails.
15
+ return step.action('posthog-capture', {
16
+ invoke: async () => {
17
+ const token = process.env.POSTHOG_TOKEN
18
+ if (!token) return { success: false, error: 'POSTHOG_TOKEN env var not set' }
19
+
20
+ const posthog = new PostHog(token, { host: process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com' })
21
+ posthog.capture({
22
+ event: input.event,
23
+ distinctId: input.distinctId,
24
+ properties: input.properties,
25
+ })
26
+ await posthog.shutdown()
27
+ return { success: true }
28
+ },
29
+ compensate: async () => {
30
+ // No-op — events are immutable.
31
+ },
32
+ })(input)
33
+ },
34
+ })
@@ -0,0 +1,4 @@
1
+ import { defineModel, fromZodSchema } from '@mantajs/core'
2
+ import { postHogEventSchema } from '../../schemas'
3
+
4
+ export default defineModel('PostHogEvent', fromZodSchema(postHogEventSchema)).external()
@@ -0,0 +1,4 @@
1
+ import { defineModel, fromZodSchema } from '@mantajs/core'
2
+ import { postHogInsightSchema } from '../../schemas'
3
+
4
+ export default defineModel('PostHogInsight', fromZodSchema(postHogInsightSchema)).external()
@@ -0,0 +1,4 @@
1
+ import { defineModel, fromZodSchema } from '@mantajs/core'
2
+ import { postHogPersonSchema } from '../../schemas'
3
+
4
+ export default defineModel('PostHogPerson', fromZodSchema(postHogPersonSchema)).external()
@@ -0,0 +1,24 @@
1
+ // PostHog query graph extension — declares which entities this module owns and routes
2
+ // Manta query graph requests to PostHog's HogQL / Insights APIs.
3
+ //
4
+ // The file is auto-discovered by the framework (scans modules/{name}/queries/*.ts).
5
+ // Because its default export uses `extendQueryGraph`, the framework registers it on
6
+ // the QueryService as a resolver for the listed entities.
7
+
8
+ import { extendQueryGraph } from '@mantajs/core'
9
+ import { executeHogQL, executeInsights } from './lib/execute'
10
+ import { SUPPORTED_FILTERS } from './lib/schema'
11
+
12
+ export default extendQueryGraph({
13
+ owns: ['posthogEvent', 'posthogPerson', 'posthogInsight'],
14
+ supportedFilters: SUPPORTED_FILTERS,
15
+ async resolve(query) {
16
+ // Case-insensitive dispatch — caller may pass 'posthogInsight', 'PostHogInsight',
17
+ // 'posthoginsight', etc. depending on where they discovered the entity name (AI
18
+ // system prompt lowercases module names, for example).
19
+ if (typeof query.entity === 'string' && query.entity.toLowerCase() === 'posthoginsight') {
20
+ return executeInsights(query)
21
+ }
22
+ return executeHogQL(query)
23
+ },
24
+ })
@@ -0,0 +1,111 @@
1
+ // PostHog HogQL execution — runs SQL against the PostHog query endpoint and normalizes results.
2
+
3
+ import type { GraphQueryConfig } from '@mantajs/core'
4
+ import { MantaError } from '@mantajs/core'
5
+ import { DEFAULT_SORT, ENTITY_TO_TABLE } from './schema'
6
+ import { normalizeRow, translateFilters } from './translate'
7
+
8
+ export interface PostHogConnection {
9
+ host: string
10
+ personalApiKey: string
11
+ }
12
+
13
+ /**
14
+ * Read PostHog connection info from environment variables. No plugin config — pure env.
15
+ * Throws MantaError if the personal API key is missing, so the caller (AI or HTTP) sees a clear message.
16
+ */
17
+ export function readPostHogConnection(): PostHogConnection {
18
+ const host = process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com'
19
+ const personalApiKey = process.env.POSTHOG_API_KEY
20
+ if (!personalApiKey) {
21
+ throw new MantaError('INVALID_DATA', 'POSTHOG_API_KEY env var not set — cannot query PostHog data warehouse.')
22
+ }
23
+ return { host, personalApiKey }
24
+ }
25
+
26
+ /**
27
+ * Resolve a query that targets posthogEvent or posthogPerson via HogQL.
28
+ *
29
+ * Entity name normalization: the extendQueryGraph() framework does a case-insensitive
30
+ * match when deciding which extension owns an entity, but passes the caller's original
31
+ * string to the resolver. Callers can therefore use 'posthogEvent', 'PostHogEvent', or
32
+ * 'posthogevent' (e.g. when the AI system prompt lowercases module names). We normalize
33
+ * against the canonical keys of ENTITY_TO_TABLE so every downstream lookup
34
+ * (translateFilters, normalizeRow) gets the canonical name.
35
+ */
36
+ export async function executeHogQL(query: GraphQueryConfig): Promise<Record<string, unknown>[]> {
37
+ const rawEntity = query.entity as string
38
+ const canonicalEntity = Object.keys(ENTITY_TO_TABLE).find((k) => k.toLowerCase() === rawEntity.toLowerCase())
39
+ if (!canonicalEntity) {
40
+ throw new MantaError(
41
+ 'INVALID_DATA',
42
+ `Unknown PostHog entity: ${rawEntity}. Known: ${Object.keys(ENTITY_TO_TABLE).join(', ')}`,
43
+ )
44
+ }
45
+ const entity = canonicalEntity
46
+ const table = ENTITY_TO_TABLE[entity]
47
+
48
+ const { host, personalApiKey } = readPostHogConnection()
49
+ const whereClause = translateFilters(entity, query.filters)
50
+ const limit = query.pagination?.limit ?? 100
51
+ const offset = query.pagination?.offset ?? 0
52
+ // Each ClickHouse table has its own time column — events.timestamp, persons.created_at.
53
+ // Pick the right one from DEFAULT_SORT, or skip ORDER BY entirely if no default is known.
54
+ const orderByClause = DEFAULT_SORT[entity] ? `ORDER BY ${DEFAULT_SORT[entity]}` : ''
55
+
56
+ const hogql = `SELECT * FROM ${table} ${whereClause} ${orderByClause} LIMIT ${limit} OFFSET ${offset}`
57
+ .replace(/\s+/g, ' ')
58
+ .trim()
59
+
60
+ const res = await fetch(`${host}/api/projects/@current/query/`, {
61
+ method: 'POST',
62
+ headers: {
63
+ Authorization: `Bearer ${personalApiKey}`,
64
+ 'Content-Type': 'application/json',
65
+ },
66
+ body: JSON.stringify({ query: { kind: 'HogQLQuery', query: hogql } }),
67
+ })
68
+
69
+ if (!res.ok) {
70
+ throw new MantaError('INVALID_DATA', `PostHog HogQL returned ${res.status}: ${await res.text()}`)
71
+ }
72
+
73
+ const data = (await res.json()) as { results?: unknown[][]; columns?: string[] }
74
+ if (!data.results || !data.columns) return []
75
+
76
+ return data.results.map((row) => {
77
+ const obj: Record<string, unknown> = {}
78
+ data.columns?.forEach((col, idx) => {
79
+ obj[col] = row[idx]
80
+ })
81
+ return normalizeRow(entity, obj)
82
+ })
83
+ }
84
+
85
+ /**
86
+ * Resolve posthogInsight via PostHog's REST endpoint (not HogQL — insights have their own API).
87
+ */
88
+ export async function executeInsights(query: GraphQueryConfig): Promise<Record<string, unknown>[]> {
89
+ const { host, personalApiKey } = readPostHogConnection()
90
+ const params = new URLSearchParams()
91
+ params.set('limit', String(query.pagination?.limit ?? 100))
92
+ if (query.filters?.id) params.set('short_id', String(query.filters.id))
93
+
94
+ const res = await fetch(`${host}/api/projects/@current/insights/?${params}`, {
95
+ headers: { Authorization: `Bearer ${personalApiKey}` },
96
+ })
97
+ if (!res.ok) {
98
+ throw new MantaError('INVALID_DATA', `PostHog Insights API returned ${res.status}: ${await res.text()}`)
99
+ }
100
+
101
+ const data = (await res.json()) as { results?: Array<Record<string, unknown>> }
102
+ return (data.results ?? []).map((r) => ({
103
+ id: r.id,
104
+ name: r.name,
105
+ description: r.description,
106
+ shortId: r.short_id,
107
+ filters: r.filters,
108
+ createdAt: r.created_at,
109
+ updatedAt: r.updated_at,
110
+ }))
111
+ }
@@ -0,0 +1,45 @@
1
+ // PostHog entity ↔ HogQL mapping — which ClickHouse tables + columns back each Manta entity.
2
+
3
+ /** Manta entity name → HogQL table name. */
4
+ export const ENTITY_TO_TABLE: Record<string, string> = {
5
+ posthogEvent: 'events',
6
+ posthogPerson: 'persons',
7
+ }
8
+
9
+ /** Manta field name → HogQL column path (supports dotted access for properties). */
10
+ export const FIELD_MAP: Record<string, Record<string, string>> = {
11
+ posthogEvent: {
12
+ id: 'uuid',
13
+ event: 'event',
14
+ distinctId: 'distinct_id',
15
+ timestamp: 'timestamp',
16
+ properties: 'properties',
17
+ url: 'properties.$current_url',
18
+ personId: 'person_id',
19
+ },
20
+ posthogPerson: {
21
+ id: 'id',
22
+ distinctId: 'distinct_id',
23
+ email: 'properties.email',
24
+ name: 'properties.name',
25
+ createdAt: 'created_at',
26
+ properties: 'properties',
27
+ },
28
+ }
29
+
30
+ /** Filters supported per entity — anything else throws at query time. */
31
+ export const SUPPORTED_FILTERS: Record<string, string[]> = {
32
+ posthogEvent: ['event', 'distinctId', 'personId', 'timestamp', 'after', 'before'],
33
+ posthogPerson: ['email', 'distinctId', 'id'],
34
+ posthogInsight: ['id', 'shortId'],
35
+ }
36
+
37
+ /**
38
+ * Default ORDER BY clause per entity. Each ClickHouse table has its own time column —
39
+ * `events.timestamp`, `persons.created_at` — and using the wrong one yields a 400
40
+ * "Unable to resolve field" error. Keyed by canonical Manta entity name.
41
+ */
42
+ export const DEFAULT_SORT: Record<string, string> = {
43
+ posthogEvent: 'timestamp DESC',
44
+ posthogPerson: 'created_at DESC',
45
+ }
@@ -0,0 +1,73 @@
1
+ // Manta query graph → HogQL SQL translator.
2
+
3
+ import { FIELD_MAP } from './schema'
4
+
5
+ /**
6
+ * Escape a raw value for safe inclusion in a HogQL string.
7
+ * Numbers and booleans are passed through, strings are single-quoted with ' → ''.
8
+ */
9
+ export function escapeValue(v: unknown): string {
10
+ if (v === null || v === undefined) return 'NULL'
11
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
12
+ return `'${String(v).replace(/'/g, "''")}'`
13
+ }
14
+
15
+ /**
16
+ * Translate a Manta filter map into a HogQL `WHERE` clause.
17
+ * Only equality, IN (array value), and the special `after`/`before` timestamp filters are supported.
18
+ */
19
+ export function translateFilters(entity: string, filters: Record<string, unknown> | undefined): string {
20
+ if (!filters || Object.keys(filters).length === 0) return ''
21
+ const fieldMap = FIELD_MAP[entity] ?? {}
22
+ const clauses: string[] = []
23
+
24
+ for (const [key, value] of Object.entries(filters)) {
25
+ if (key === 'after') {
26
+ clauses.push(`timestamp > ${escapeValue(value)}`)
27
+ continue
28
+ }
29
+ if (key === 'before') {
30
+ clauses.push(`timestamp < ${escapeValue(value)}`)
31
+ continue
32
+ }
33
+ const column = fieldMap[key] ?? key
34
+ if (Array.isArray(value)) {
35
+ const list = value.map(escapeValue).join(', ')
36
+ clauses.push(`${column} IN (${list})`)
37
+ } else {
38
+ clauses.push(`${column} = ${escapeValue(value)}`)
39
+ }
40
+ }
41
+
42
+ return clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
43
+ }
44
+
45
+ /**
46
+ * Normalize a raw HogQL result row (keyed by ClickHouse column names) into the Manta entity shape.
47
+ */
48
+ export function normalizeRow(entity: string, row: Record<string, unknown>): Record<string, unknown> {
49
+ if (entity === 'posthogEvent') {
50
+ const props = (row.properties as Record<string, unknown> | null) ?? {}
51
+ return {
52
+ id: row.uuid ?? row.id,
53
+ event: row.event,
54
+ distinctId: row.distinct_id,
55
+ timestamp: row.timestamp,
56
+ properties: props,
57
+ url: props.$current_url ?? null,
58
+ personId: row.person_id ?? null,
59
+ }
60
+ }
61
+ if (entity === 'posthogPerson') {
62
+ const props = (row.properties as Record<string, unknown> | null) ?? {}
63
+ return {
64
+ id: row.id,
65
+ distinctId: row.distinct_id,
66
+ email: (props.email as string | undefined) ?? null,
67
+ name: (props.name as string | undefined) ?? null,
68
+ createdAt: row.created_at,
69
+ properties: props,
70
+ }
71
+ }
72
+ return row
73
+ }
@@ -0,0 +1,48 @@
1
+ // Generated by ts-to-zod
2
+ import { z } from 'zod'
3
+
4
+ export const postHogCaptureInputSchema = z.object({
5
+ distinctId: z.string(),
6
+ event: z.string(),
7
+ properties: z.record(z.string(), z.unknown()).optional(),
8
+ timestamp: z.date().optional(),
9
+ uuid: z.string().optional(),
10
+ })
11
+
12
+ export const postHogIdentifyInputSchema = z.object({
13
+ distinctId: z.string(),
14
+ properties: z.record(z.string(), z.unknown()).optional(),
15
+ })
16
+
17
+ const _assertCaptureMatchesSdkSchema = z.any()
18
+
19
+ const _assertIdentifyMatchesSdkSchema = z.any()
20
+
21
+ export const postHogEventSchema = z.object({
22
+ uuid: z.string(),
23
+ event: z.string(),
24
+ distinctId: z.string(),
25
+ timestamp: z.string(),
26
+ properties: z.record(z.string(), z.unknown()),
27
+ personId: z.string().nullable(),
28
+ url: z.string().nullable(),
29
+ })
30
+
31
+ export const postHogPersonSchema = z.object({
32
+ id: z.string(),
33
+ distinctId: z.string(),
34
+ email: z.string().nullable(),
35
+ name: z.string().nullable(),
36
+ createdAt: z.string(),
37
+ properties: z.record(z.string(), z.unknown()),
38
+ })
39
+
40
+ export const postHogInsightSchema = z.object({
41
+ id: z.number(),
42
+ shortId: z.string().nullable(),
43
+ name: z.string(),
44
+ description: z.string().nullable(),
45
+ filters: z.record(z.string(), z.unknown()),
46
+ createdAt: z.string(),
47
+ updatedAt: z.string().nullable(),
48
+ })