Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { McpModule } from './mcp/mcp.module';
import { MetricForecastingModule } from './metric-forecasting/metric-forecasting.module';
import { InferenceLatencyModule } from './inference-latency/inference-latency.module';
import { CliModule } from './cli/cli.module';
import { PosthogProxyModule } from './posthog-proxy/posthog-proxy.module';

let AiModule: any = null;
let LicenseModule: any = null;
Expand Down Expand Up @@ -136,6 +137,7 @@ const baseImports = [
MetricForecastingModule,
InferenceLatencyModule,
CliModule,
PosthogProxyModule,
];

const proprietaryImports = [
Expand Down
6 changes: 4 additions & 2 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { INestApplication, Logger, ValidationPipe } from '@nestjs/common';
import { INestApplication, Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
Expand Down Expand Up @@ -138,7 +138,9 @@ async function bootstrap(): Promise<void> {
if (isProduction && publicPath) {
// Set global prefix for API routes
// SPA fallback is registered at Fastify level before NestJS, so no exclusion needed
app.setGlobalPrefix('api');
app.setGlobalPrefix('api', {
exclude: [{ path: 'ingest/*splat', method: RequestMethod.ALL }],
});

// Serve static files from public directory (publicPath computed above)
const fastifyInstance = app.getHttpAdapter().getInstance();
Expand Down
154 changes: 154 additions & 0 deletions apps/api/src/posthog-proxy/__tests__/posthog-proxy.controller.spec.ts
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');
});
});
});
34 changes: 34 additions & 0 deletions apps/api/src/posthog-proxy/posthog-proxy.controller.ts
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')
Comment thread
cursor[bot] marked this conversation as resolved.
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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Body always JSON-stringified regardless of content type

Medium Severity

The proxy always re-encodes the body with JSON.stringify(req.body) but faithfully forwards the original content-type header. When a request arrives with a non-JSON content type (e.g., text/plain, which PostHog SDK uses for sendBeacon payloads), Fastify parses req.body as a raw string. Calling JSON.stringify on a string wraps it in quotes and escapes it, producing a double-encoded payload while the forwarded content-type still says text/plain. PostHog would fail to parse this mismatched request. Forwarding the raw body instead of always re-serializing as JSON would handle all content types correctly.

Fix in Cursor Fix in Web

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);
}
}
7 changes: 7 additions & 0 deletions apps/api/src/posthog-proxy/posthog-proxy.module.ts
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 {}
105 changes: 105 additions & 0 deletions apps/api/test/api-posthog-proxy.e2e-spec.ts
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);
});
});
});
7 changes: 7 additions & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ export default defineConfig({
envDir: path.resolve(__dirname, '../..'),
server: {
port: 5173,
proxy: {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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/, ''),
},
},
Comment thread
cursor[bot] marked this conversation as resolved.
},
resolve: {
alias: {
Expand Down
Loading