Skip to content

feat: move rrweb event stream to client and query through /api/clickhouse-proxy #755

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

Merged
merged 13 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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
7 changes: 7 additions & 0 deletions .changeset/neat-badgers-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/api": patch
"@hyperdx/app": patch
---

feat: move rrweb event fetching to the client instead of an api route
1 change: 0 additions & 1 deletion packages/api/src/api-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ app.use('/dashboards', isUserAuthenticated, routers.dashboardRouter);
app.use('/logs', isUserAuthenticated, routers.logsRouter);
app.use('/me', isUserAuthenticated, routers.meRouter);
app.use('/metrics', isUserAuthenticated, routers.metricsRouter);
app.use('/sessions', isUserAuthenticated, routers.sessionsRouter);
app.use('/team', isUserAuthenticated, routers.teamRouter);
app.use('/webhooks', isUserAuthenticated, routers.webhooksRouter);
app.use('/chart', isUserAuthenticated, routers.chartRouter);
Expand Down
55 changes: 37 additions & 18 deletions packages/api/src/routers/api/clickhouseProxy.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import express, { Request, Response } from 'express';
import express, { RequestHandler, Response } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { z } from 'zod';
import { validateRequest } from 'zod-express-middleware';

import { getConnectionById } from '@/controllers/connection';
import { getNonNullUserWithTeam } from '@/middleware/auth';
import { validateRequestHeaders } from '@/utils/validation';
import { objectIdSchema } from '@/utils/zod';

const router = express.Router();
Expand Down Expand Up @@ -58,17 +59,23 @@ router.post(
},
);

router.get(
'/*',
validateRequest({
query: z.object({
hyperdx_connection_id: objectIdSchema,
function validation() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

handling both GET and POST routes now, so broke into RequestHandlers that both can use

Copy link
Member

Choose a reason for hiding this comment

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

style: we don't need to wrap the validate middleware within another function, right? can just be a var

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I was doing it mainly to make the PR easily reviewable (other functions would reformat and indent lines that otherwise haven't changed) but I'll just add some prettier commands for the time being

return validateRequestHeaders(
z.object({
'x-hyperdx-connection-id': objectIdSchema,
}),
}),
async (req, res, next) => {
);
}

function getConnection(): RequestHandler {
return async (req, res, next) => {
try {
const { teamId } = getNonNullUserWithTeam(req);
const { hyperdx_connection_id } = req.query;
const connection_id = req.headers['x-hyperdx-connection-id']!; // ! because zod already validated
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Header x-hyperdx-connection-id is now in place of the respective queryparam. I chose this based on the clickhouse documentation for a reverse-proxy in front of a clickhouse instance. They do have a section that allows you to send query params as well, but those are all prefixed with 'param_', which quickly gets messy with zod validating either query param 'hyperdx_connection_id' or 'param_hyperdx_connection_id', so I opted to move to using headers.

delete req.headers['x-hyperdx-connection-id'];
const hyperdx_connection_id = Array.isArray(connection_id)
? connection_id.join('')
: connection_id;

const connection = await getConnectionById(
teamId.toString(),
Expand All @@ -93,13 +100,15 @@ router.get(
console.error('Error fetching connection info:', e);
next(e);
}
},
createProxyMiddleware({
};
}

function proxyMiddleware(): RequestHandler {
return createProxyMiddleware({
target: '', // doesn't matter. it should be overridden by the router
changeOrigin: true,
pathFilter: (path, _req) => {
// TODO: allow other methods
return _req.method === 'GET';
return _req.method === 'GET' || _req.method === 'POST';
},
pathRewrite: {
'^/clickhouse-proxy': '',
Expand All @@ -113,16 +122,23 @@ router.get(
on: {
proxyReq: (proxyReq, _req) => {
const newPath = _req.params[0];
const qparams = new URLSearchParams(_req.query);
qparams.delete('hyperdx_connection_id');
let qparams = '';
const qIdx = _req.url.indexOf('?');
if (qIdx >= 0) {
qparams = _req.url.substring(qIdx);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

_req.query is type ParamQs, which does not necessarily play nicely with URLSearchParams. Since we now forward all query params, prefer to just grab the string from the url

Copy link
Member

Choose a reason for hiding this comment

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

will this introduce any encoding issue? I'd suggest to move all irrelevant changes to a separate PR so folks can review it easier

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will do

if (_req._hdx_connection?.username && _req._hdx_connection?.password) {
proxyReq.setHeader(
'X-ClickHouse-User',
_req._hdx_connection.username,
);
proxyReq.setHeader('X-ClickHouse-Key', _req._hdx_connection.password);
}
proxyReq.path = `/${newPath}?${qparams.toString()}`;
if (_req.method === 'POST') {
// TODO: Use fixRequestBody after this issue is resolved: https://github.com/chimurai/http-proxy-middleware/issues/1102
proxyReq.write(_req.body);
}
proxyReq.path = `/${newPath}${qparams}`;
},
proxyRes: (proxyRes, _req, res) => {
// since clickhouse v24, the cors headers * will be attached to the response by default
Expand Down Expand Up @@ -158,7 +174,10 @@ router.get(
// ...(config.IS_DEV && {
// logger: console,
// }),
}),
);
});
}

router.get('/*', validation(), getConnection(), proxyMiddleware());
router.post('/*', validation(), getConnection(), proxyMiddleware());

export default router;
2 changes: 0 additions & 2 deletions packages/api/src/routers/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import logsRouter from './logs';
import meRouter from './me';
import metricsRouter from './metrics';
import rootRouter from './root';
import sessionsRouter from './sessions';
import teamRouter from './team';
import webhooksRouter from './webhooks';

Expand All @@ -18,7 +17,6 @@ export default {
meRouter,
metricsRouter,
rootRouter,
sessionsRouter,
teamRouter,
webhooksRouter,
chartRouter,
Expand Down
152 changes: 0 additions & 152 deletions packages/api/src/routers/api/sessions.ts

This file was deleted.

17 changes: 17 additions & 0 deletions packages/api/src/utils/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import express from 'express';
Copy link
Member

Choose a reason for hiding this comment

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

nit: we should probably put this in the middleware dir

import { z } from 'zod';

export function validateRequestHeaders<T extends z.Schema>(schema: T) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Basically the same as validateRequest function, but they don't validate headers. Maybe should submit a feature request

return function (
req: express.Request,
res: express.Response,
next: express.NextFunction,
) {
const parsed = schema.safeParse(req.headers);
if (!parsed.success) {
return res.status(400).json({ type: 'Headers', errors: parsed.error });
}

return next();
};
}
9 changes: 2 additions & 7 deletions packages/app/pages/api/[...all].ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ const DEFAULT_SERVER_URL = `http://127.0.0.1:${process.env.HYPERDX_API_PORT}`;
export const config = {
api: {
externalResolver: true,
bodyParser: true,
bodyParser: false,
responseLimit: '32mb',
Copy link
Member

Choose a reason for hiding this comment

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

Can you elaborate on the decision behind this number?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I saw it as good practice to implement a responseLimit online and felt it might not be best security to allow unlimited data to flow through, so I chose a number I also saw being used on the express bodyParser middleware for json and text in api-app.ts. This may not be ideal though, especially since we already allow the client to potentially send a massive query to the db, which is where the performance hit would really come from.

For now I'm removing the responseLimit. I'll look to your guidance for the right thing to do on this

},
};

Expand All @@ -17,12 +18,6 @@ export default (req: NextApiRequest, res: NextApiResponse) => {
pathRewrite: { '^/api': '' },
target: process.env.NEXT_PUBLIC_SERVER_URL || DEFAULT_SERVER_URL,
autoRewrite: true,
/**
* Fix bodyParser
**/
on: {
proxyReq: fixRequestBody,
},
// ...(IS_DEV && {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The function fixRequestBody does not handle Content-Type: 'text/plain', which makes things horribly annoying. Here's what happens:

  1. bodyParser.text reads the incoming stream to req.body
  2. fixRequestBody looks at the content type and sees 'text/plain', but it doesn't handle that, so it does not write to the proxy request
  3. The proxy request is sent to our express server with a non-zero content-length but an empty body
  4. The express server's express.text middleware (bodyParser under the hood) will see text/plain and a Content-Length > 0, so it tries to read the incoming stream and parse it into req.body
  5. The stream never comes in, so next() is never called. The server will just wait ad infinitum.

I filed an issue and PR to fix fixRequestBody. But we don't even need bodyParser here, so just disabling it and forwarding everything works too.

// logger: console,
// }),
Expand Down
Loading