-
Notifications
You must be signed in to change notification settings - Fork 53
feature: add PostHog reverse proxy to bypass ad blockers #145
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e17fbc3
68f47e0
d4ddbc3
5d3a039
b8d45ec
64660c1
a30163f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| import { PosthogProxyController } from '../posthog-proxy.controller'; | ||
|
|
||
| const POSTHOG_HOST = process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com'; | ||
|
|
||
| function makeReply() { | ||
| const reply = { | ||
| status: jest.fn(), | ||
| header: jest.fn(), | ||
| send: jest.fn(), | ||
| }; | ||
| reply.status.mockReturnValue(reply); | ||
| reply.header.mockReturnValue(reply); | ||
| return reply; | ||
| } | ||
|
|
||
| function makeReq(overrides: Partial<{ method: string; url: string; headers: Record<string, string>; body: unknown }> = {}) { | ||
| return { | ||
| method: 'POST', | ||
| url: '/ingest/e/', | ||
| headers: { 'content-type': 'application/json' }, | ||
| body: { event: 'pageview', distinct_id: 'user_1' }, | ||
| ...overrides, | ||
| } as any; | ||
| } | ||
|
|
||
| function mockFetchResponse(status: number, body: string, contentType = 'application/json') { | ||
| return jest.spyOn(global, 'fetch').mockResolvedValueOnce({ | ||
| status, | ||
| text: () => Promise.resolve(body), | ||
| headers: { get: (k: string) => (k === 'content-type' ? contentType : null) }, | ||
| } as any); | ||
| } | ||
|
|
||
| describe('PosthogProxyController', () => { | ||
| let controller: PosthogProxyController; | ||
|
|
||
| beforeEach(() => { | ||
| controller = new PosthogProxyController(); | ||
| jest.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe('URL routing', () => { | ||
| it('forwards /ingest/e/ → {POSTHOG_HOST}/e/', async () => { | ||
| const fetchSpy = mockFetchResponse(200, '{"status":1}'); | ||
| await controller.proxy(makeReq({ url: '/ingest/e/' }), makeReply() as any); | ||
| expect(fetchSpy).toHaveBeenCalledWith(`${POSTHOG_HOST}/e/`, expect.any(Object)); | ||
| }); | ||
|
|
||
| it('forwards /ingest/decide → {POSTHOG_HOST}/decide', async () => { | ||
| const fetchSpy = mockFetchResponse(200, '{}'); | ||
| await controller.proxy(makeReq({ url: '/ingest/decide', method: 'POST' }), makeReply() as any); | ||
| expect(fetchSpy).toHaveBeenCalledWith(`${POSTHOG_HOST}/decide`, expect.any(Object)); | ||
| }); | ||
|
|
||
| it('strips /api/ingest prefix in production', async () => { | ||
| const fetchSpy = mockFetchResponse(200, '{}'); | ||
| await controller.proxy(makeReq({ url: '/api/ingest/batch/', method: 'POST' }), makeReply() as any); | ||
| expect(fetchSpy).toHaveBeenCalledWith(`${POSTHOG_HOST}/batch/`, expect.any(Object)); | ||
| }); | ||
|
|
||
| it('preserves query strings', async () => { | ||
| const fetchSpy = mockFetchResponse(200, '{}'); | ||
| await controller.proxy(makeReq({ url: '/ingest/decide?v=1&token=abc', method: 'POST' }), makeReply() as any); | ||
| expect(fetchSpy).toHaveBeenCalledWith(`${POSTHOG_HOST}/decide?v=1&token=abc`, expect.any(Object)); | ||
| }); | ||
| }); | ||
|
|
||
| describe('request forwarding', () => { | ||
| it('forwards POST body as JSON', async () => { | ||
| const fetchSpy = mockFetchResponse(200, '{"status":1}'); | ||
| const body = { event: 'click', distinct_id: 'u1' }; | ||
| await controller.proxy(makeReq({ body, method: 'POST' }), makeReply() as any); | ||
| expect(fetchSpy).toHaveBeenCalledWith( | ||
| expect.any(String), | ||
| expect.objectContaining({ method: 'POST', body: JSON.stringify(body) }), | ||
| ); | ||
| }); | ||
|
|
||
| it('sends no body for GET requests', async () => { | ||
| const fetchSpy = mockFetchResponse(200, '{}'); | ||
| await controller.proxy(makeReq({ method: 'GET', url: '/ingest/flags/' }), makeReply() as any); | ||
| expect(fetchSpy).toHaveBeenCalledWith( | ||
| expect.any(String), | ||
| expect.objectContaining({ body: undefined }), | ||
| ); | ||
| }); | ||
|
|
||
| it('forwards content-type header', async () => { | ||
| const fetchSpy = mockFetchResponse(200, '{}'); | ||
| await controller.proxy( | ||
| makeReq({ headers: { 'content-type': 'application/json; charset=utf-8' } }), | ||
| makeReply() as any, | ||
| ); | ||
| expect(fetchSpy).toHaveBeenCalledWith( | ||
| expect.any(String), | ||
| expect.objectContaining({ | ||
| headers: expect.objectContaining({ 'content-type': 'application/json; charset=utf-8' }), | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| it('defaults content-type to application/json when absent', async () => { | ||
| const fetchSpy = mockFetchResponse(200, '{}'); | ||
| await controller.proxy(makeReq({ headers: {} }), makeReply() as any); | ||
| expect(fetchSpy).toHaveBeenCalledWith( | ||
| expect.any(String), | ||
| expect.objectContaining({ | ||
| headers: expect.objectContaining({ 'content-type': 'application/json' }), | ||
| }), | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('response forwarding', () => { | ||
| it('returns the upstream status code', async () => { | ||
| mockFetchResponse(200, '{"status":1}'); | ||
| const reply = makeReply(); | ||
| await controller.proxy(makeReq(), reply as any); | ||
| expect(reply.status).toHaveBeenCalledWith(200); | ||
| }); | ||
|
|
||
| it('forwards upstream error status codes', async () => { | ||
| mockFetchResponse(400, '{"error":"bad request"}'); | ||
| const reply = makeReply(); | ||
| await controller.proxy(makeReq(), reply as any); | ||
| expect(reply.status).toHaveBeenCalledWith(400); | ||
| }); | ||
|
|
||
| it('forwards the upstream response body', async () => { | ||
| mockFetchResponse(200, '{"status":1}'); | ||
| const reply = makeReply(); | ||
| await controller.proxy(makeReq(), reply as any); | ||
| expect(reply.send).toHaveBeenCalledWith('{"status":1}'); | ||
| }); | ||
|
|
||
| it('forwards the upstream content-type header', async () => { | ||
| mockFetchResponse(200, '{"status":1}', 'application/json; charset=utf-8'); | ||
| const reply = makeReply(); | ||
| await controller.proxy(makeReq(), reply as any); | ||
| expect(reply.header).toHaveBeenCalledWith('content-type', 'application/json; charset=utf-8'); | ||
| }); | ||
|
|
||
| it('defaults content-type to application/json when upstream omits it', async () => { | ||
| jest.spyOn(global, 'fetch').mockResolvedValueOnce({ | ||
| status: 200, | ||
| text: () => Promise.resolve('{}'), | ||
| headers: { get: () => null }, | ||
| } as any); | ||
| const reply = makeReply(); | ||
| await controller.proxy(makeReq(), reply as any); | ||
| expect(reply.header).toHaveBeenCalledWith('content-type', 'application/json'); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { All, Controller, Req, Res } from '@nestjs/common'; | ||
| import { SkipThrottle } from '@nestjs/throttler'; | ||
| import { FastifyRequest, FastifyReply } from 'fastify'; | ||
|
|
||
| const POSTHOG_HOST = process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com'; | ||
|
|
||
| @SkipThrottle() | ||
| @Controller('ingest') | ||
| export class PosthogProxyController { | ||
| @All('*') | ||
| async proxy( | ||
| @Req() req: FastifyRequest, | ||
| @Res({ passthrough: false }) reply: FastifyReply, | ||
| ): Promise<void> { | ||
| // Strip everything up to and including /ingest to get the downstream path + query string | ||
| const ingestIdx = req.url.indexOf('/ingest'); | ||
| const downstream = ingestIdx >= 0 ? req.url.slice(ingestIdx + '/ingest'.length) : '/'; | ||
| const targetUrl = `${POSTHOG_HOST}${downstream}`; | ||
|
|
||
| const hasBody = !['GET', 'HEAD'].includes(req.method); | ||
| const response = await fetch(targetUrl, { | ||
| method: req.method, | ||
| headers: { | ||
| 'content-type': (req.headers['content-type'] as string) ?? 'application/json', | ||
| }, | ||
| body: hasBody ? JSON.stringify(req.body) : undefined, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Body always JSON-stringified regardless of content typeMedium Severity The proxy always re-encodes the body with Reviewed by Cursor Bugbot for commit a30163f. Configure here. |
||
| }); | ||
|
|
||
| const contentType = response.headers.get('content-type') ?? 'application/json'; | ||
| const body = await response.text(); | ||
|
|
||
| reply.status(response.status).header('content-type', contentType).send(body); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { Module } from '@nestjs/common'; | ||
| import { PosthogProxyController } from './posthog-proxy.controller'; | ||
|
|
||
| @Module({ | ||
| controllers: [PosthogProxyController], | ||
| }) | ||
| export class PosthogProxyModule {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { NestFastifyApplication } from '@nestjs/platform-fastify'; | ||
| import request from 'supertest'; | ||
| import { createTestApp } from './test-utils'; | ||
|
|
||
| const POSTHOG_HOST = process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com'; | ||
|
|
||
| function mockPosthog(status: number, body: string) { | ||
| return jest.spyOn(global, 'fetch').mockResolvedValueOnce({ | ||
| status, | ||
| text: () => Promise.resolve(body), | ||
| headers: { get: (k: string) => (k === 'content-type' ? 'application/json' : null) }, | ||
| } as any); | ||
| } | ||
|
|
||
| describe('PostHog Proxy (E2E)', () => { | ||
| let app: NestFastifyApplication; | ||
|
|
||
| beforeAll(async () => { | ||
| app = await createTestApp(); | ||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| await app.close(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| jest.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe('POST /ingest/e/', () => { | ||
| it('proxies event capture and returns upstream response', async () => { | ||
| const fetchSpy = mockPosthog(200, '{"status":1}'); | ||
|
|
||
| const res = await request(app.getHttpServer()) | ||
| .post('/ingest/e/') | ||
| .send({ event: 'pageview', distinct_id: 'user_1' }) | ||
| .expect(200); | ||
|
|
||
| expect(res.body).toEqual({ status: 1 }); | ||
| expect(fetchSpy).toHaveBeenCalledWith( | ||
| `${POSTHOG_HOST}/e/`, | ||
| expect.objectContaining({ method: 'POST' }), | ||
| ); | ||
| }); | ||
|
|
||
| it('forwards the request body to PostHog', async () => { | ||
| const fetchSpy = mockPosthog(200, '{"status":1}'); | ||
| const payload = { event: 'click', distinct_id: 'user_2', properties: { btn: 'signup' } }; | ||
|
|
||
| await request(app.getHttpServer()).post('/ingest/e/').send(payload).expect(200); | ||
|
|
||
| expect(fetchSpy).toHaveBeenCalledWith( | ||
| expect.any(String), | ||
| expect.objectContaining({ body: JSON.stringify(payload) }), | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('POST /ingest/decide', () => { | ||
| it('proxies feature flag evaluation', async () => { | ||
| const fetchSpy = mockPosthog(200, '{"featureFlags":{"my-flag":true}}'); | ||
|
|
||
| const res = await request(app.getHttpServer()) | ||
| .post('/ingest/decide') | ||
| .send({ token: 'abc', distinct_id: 'user_1' }) | ||
| .expect(200); | ||
|
|
||
| expect(res.body).toHaveProperty('featureFlags'); | ||
| expect(fetchSpy).toHaveBeenCalledWith(`${POSTHOG_HOST}/decide`, expect.any(Object)); | ||
| }); | ||
| }); | ||
|
|
||
| describe('POST /ingest/batch/', () => { | ||
| it('proxies batch event ingestion', async () => { | ||
| const fetchSpy = mockPosthog(200, '{"status":1}'); | ||
|
|
||
| await request(app.getHttpServer()) | ||
| .post('/ingest/batch/') | ||
| .send({ batch: [{ event: 'e1', distinct_id: 'u1' }] }) | ||
| .expect(200); | ||
|
|
||
| expect(fetchSpy).toHaveBeenCalledWith(`${POSTHOG_HOST}/batch/`, expect.any(Object)); | ||
| }); | ||
| }); | ||
|
|
||
| describe('error handling', () => { | ||
| it('forwards upstream 400 status', async () => { | ||
| mockPosthog(400, '{"error":"invalid token"}'); | ||
|
|
||
| await request(app.getHttpServer()) | ||
| .post('/ingest/e/') | ||
| .send({ event: 'x' }) | ||
| .expect(400); | ||
| }); | ||
|
|
||
| it('forwards upstream 503 status', async () => { | ||
| mockPosthog(503, '{"error":"service unavailable"}'); | ||
|
|
||
| await request(app.getHttpServer()) | ||
| .post('/ingest/e/') | ||
| .send({ event: 'x' }) | ||
| .expect(503); | ||
| }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,13 @@ export default defineConfig({ | |
| envDir: path.resolve(__dirname, '../..'), | ||
| server: { | ||
| port: 5173, | ||
| proxy: { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we have the controller, why don't we proxy to it directly?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's one fewer network hop (browser → Vite → PostHog vs browser → Vite → NestJS → PostHog) |
||
| '/ingest': { | ||
| target: process.env.POSTHOG_HOST ?? 'https://eu.i.posthog.com', | ||
| changeOrigin: true, | ||
| rewrite: (path) => path.replace(/^\/ingest/, ''), | ||
| }, | ||
| }, | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| }, | ||
| resolve: { | ||
| alias: { | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.