Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/basepath-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@hyperdx/app': minor
'@hyperdx/api': minor
'@hyperdx/common-utils': minor
---

feat: Add basePath support for subpath deployments via environment variables
5 changes: 5 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,10 @@ HYPERDX_APP_URL=http://localhost
HYPERDX_LOG_LEVEL=debug
HYPERDX_OPAMP_PORT=4320

# Subpath support (leave empty for root)
HYPERDX_BASE_PATH=
HYPERDX_API_BASE_PATH=
HYPERDX_OTEL_BASE_PATH=

# Otel/Clickhouse config
HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE=default
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ services:
HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE: ${HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE}
HYPERDX_LOG_LEVEL: ${HYPERDX_LOG_LEVEL}
OPAMP_SERVER_URL: 'http://app:${HYPERDX_OPAMP_PORT}'
HYPERDX_OTEL_BASE_PATH: ${HYPERDX_OTEL_BASE_PATH:-}
ports:
- '13133:13133' # health_check extension
- '24225:24225' # fluentd receiver
Expand Down Expand Up @@ -62,6 +63,9 @@ services:
OTEL_EXPORTER_OTLP_ENDPOINT: 'http://otel-collector:4318'
OTEL_SERVICE_NAME: 'hdx-oss-app'
USAGE_STATS_ENABLED: ${USAGE_STATS_ENABLED:-true}
HYPERDX_BASE_PATH: ${HYPERDX_BASE_PATH:-}
HYPERDX_API_BASE_PATH: ${HYPERDX_API_BASE_PATH:-}
HYPERDX_OTEL_BASE_PATH: ${HYPERDX_OTEL_BASE_PATH:-}
DEFAULT_CONNECTIONS:
'[{"name":"Local
ClickHouse","host":"http://ch-server:8123","username":"default","password":""}]'
Expand Down
7 changes: 7 additions & 0 deletions docker/hyperdx/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ RUN yarn install --mode=skip-build && yarn cache clean
## API/APP Builder Image ##########################################################################
FROM node_base AS builder

ARG HYPERDX_BASE_PATH
ARG HYPERDX_API_BASE_PATH
ARG HYPERDX_OTEL_BASE_PATH

WORKDIR /app

COPY --from=api ./src ./packages/api/src
Expand All @@ -48,6 +52,9 @@ COPY --from=app ./types ./packages/app/types
ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_OUTPUT_STANDALONE true
ENV NEXT_PUBLIC_IS_LOCAL_MODE false
ENV HYPERDX_BASE_PATH $HYPERDX_BASE_PATH
ENV HYPERDX_API_BASE_PATH $HYPERDX_API_BASE_PATH
ENV HYPERDX_OTEL_BASE_PATH $HYPERDX_OTEL_BASE_PATH
ENV NX_DAEMON=false
RUN npx nx run-many --target=build --projects=@hyperdx/common-utils,@hyperdx/api,@hyperdx/app

Expand Down
120 changes: 83 additions & 37 deletions packages/api/src/api-app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getApiBasePath } from '@hyperdx/common-utils/dist/basePath';
import compression from 'compression';
import MongoStore from 'connect-mongo';
import express from 'express';
Expand All @@ -20,6 +21,8 @@ import passport from './utils/passport';

const app: express.Application = express();

const API_BASE_PATH = getApiBasePath();

const sess: session.SessionOptions & { cookie: session.CookieOptions } = {
resave: false,
saveUninitialized: false,
Expand Down Expand Up @@ -79,45 +82,88 @@ if (config.USAGE_STATS_ENABLED) {
// ---------------------------------------------------------------------
// ----------------------- Internal Routers ----------------------------
// ---------------------------------------------------------------------
// PUBLIC ROUTES
app.use('/', routers.rootRouter);

// PRIVATE ROUTES
app.use('/alerts', isUserAuthenticated, routers.alertsRouter);
app.use('/dashboards', isUserAuthenticated, routers.dashboardRouter);
app.use('/me', isUserAuthenticated, routers.meRouter);
app.use('/team', isUserAuthenticated, routers.teamRouter);
app.use('/webhooks', isUserAuthenticated, routers.webhooksRouter);
app.use('/connections', isUserAuthenticated, connectionsRouter);
app.use('/sources', isUserAuthenticated, sourcesRouter);
app.use('/saved-search', isUserAuthenticated, savedSearchRouter);
app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter);
// ---------------------------------------------------------------------
if (API_BASE_PATH) {
const apiRouter = express.Router();

// TODO: Separate external API routers from internal routers
// ---------------------------------------------------------------------
// ----------------------- External Routers ----------------------------
// ---------------------------------------------------------------------
// API v2
// Only initialize Swagger in development or if explicitly enabled
if (
process.env.NODE_ENV !== 'production' &&
process.env.ENABLE_SWAGGER === 'true'
) {
import('./utils/swagger')
.then(({ setupSwagger }) => {
console.log('Swagger UI setup and available at /api/v2/docs');
setupSwagger(app);
})
.catch(error => {
console.error(
'Failed to dynamically load or setup Swagger. Swagger UI will not be available.',
error,
);
});
}
// PUBLIC ROUTES
apiRouter.use('/', routers.rootRouter);

// PRIVATE ROUTES
apiRouter.use('/alerts', isUserAuthenticated, routers.alertsRouter);
apiRouter.use('/dashboards', isUserAuthenticated, routers.dashboardRouter);
apiRouter.use('/me', isUserAuthenticated, routers.meRouter);
apiRouter.use('/team', isUserAuthenticated, routers.teamRouter);
apiRouter.use('/webhooks', isUserAuthenticated, routers.webhooksRouter);
apiRouter.use('/connections', isUserAuthenticated, connectionsRouter);
apiRouter.use('/sources', isUserAuthenticated, sourcesRouter);
apiRouter.use('/saved-search', isUserAuthenticated, savedSearchRouter);
apiRouter.use(
'/clickhouse-proxy',
isUserAuthenticated,
clickhouseProxyRouter,
);
apiRouter.use('/api/v2', externalRoutersV2);

// Only initialize Swagger in development or if explicitly enabled
if (
process.env.NODE_ENV !== 'production' &&
process.env.ENABLE_SWAGGER === 'true'
) {
import('./utils/swagger')
.then(({ setupSwagger }) => {
console.log('Swagger UI setup and available at /api/v2/docs');
setupSwagger(app);
})
.catch(error => {
console.error(
'Failed to dynamically load or setup Swagger. Swagger UI will not be available.',
error,
);
});
}

app.use(API_BASE_PATH, apiRouter);
} else {
// PUBLIC ROUTES
app.use('/', routers.rootRouter);

app.use('/api/v2', externalRoutersV2);
// PRIVATE ROUTES
app.use('/alerts', isUserAuthenticated, routers.alertsRouter);
app.use('/dashboards', isUserAuthenticated, routers.dashboardRouter);
app.use('/me', isUserAuthenticated, routers.meRouter);
app.use('/team', isUserAuthenticated, routers.teamRouter);
app.use('/webhooks', isUserAuthenticated, routers.webhooksRouter);
app.use('/connections', isUserAuthenticated, connectionsRouter);
app.use('/sources', isUserAuthenticated, sourcesRouter);
app.use('/saved-search', isUserAuthenticated, savedSearchRouter);
app.use('/clickhouse-proxy', isUserAuthenticated, clickhouseProxyRouter);

// TODO: Separate external API routers from internal routers
// ---------------------------------------------------------------------
// ----------------------- External Routers ----------------------------
// ---------------------------------------------------------------------
// API v2
// Only initialize Swagger in development or if explicitly enabled
if (
process.env.NODE_ENV !== 'production' &&
process.env.ENABLE_SWAGGER === 'true'
) {
import('./utils/swagger')
.then(({ setupSwagger }) => {
console.log('Swagger UI setup and available at /api/v2/docs');
setupSwagger(app);
})
.catch(error => {
console.error(
'Failed to dynamically load or setup Swagger. Swagger UI will not be available.',
error,
);
});
}

app.use('/api/v2', externalRoutersV2);
}
// ---------------------------------------------------------------------

// error handling
app.use(appErrorHandler);
Expand Down
2 changes: 2 additions & 0 deletions packages/app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,11 @@ FROM base AS builder
ARG OTEL_EXPORTER_OTLP_ENDPOINT
ARG OTEL_SERVICE_NAME
ARG IS_LOCAL_MODE
ARG HYPERDX_BASE_PATH
ENV NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT $OTEL_EXPORTER_OTLP_ENDPOINT
ENV NEXT_PUBLIC_OTEL_SERVICE_NAME $OTEL_SERVICE_NAME
ENV NEXT_PUBLIC_IS_LOCAL_MODE $IS_LOCAL_MODE
ENV HYPERDX_BASE_PATH $HYPERDX_BASE_PATH
ENV NX_DAEMON false

COPY ./packages/app/src ./packages/app/src
Expand Down
6 changes: 4 additions & 2 deletions packages/app/next.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const { configureRuntimeEnv } = require('next-runtime-env/build/configure');
const { version } = require('./package.json');
const { getFrontendBasePath } = require('@hyperdx/common-utils/dist/basePath');

configureRuntimeEnv();

Expand All @@ -9,6 +10,7 @@ const withNextra = require('nextra')({
});

module.exports = {
basePath: getFrontendBasePath(),
experimental: {
instrumentationHook: true,
// External packages to prevent bundling issues with Next.js 14
Expand Down Expand Up @@ -57,8 +59,8 @@ module.exports = {
productionBrowserSourceMaps: false,
...(process.env.NEXT_OUTPUT_STANDALONE === 'true'
? {
output: 'standalone',
}
output: 'standalone',
}
: {}),
}),
};
3 changes: 2 additions & 1 deletion packages/app/pages/_document.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Head, Html, Main, NextScript } from 'next/document';
import { getFrontendBasePath } from '@hyperdx/common-utils/dist/basePath';

export default function Document() {
return (
<Html lang="en">
<Head>
<script src="/__ENV.js" />
<script src={`${getFrontendBasePath()}/__ENV.js`} />
<script src="https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js"></script>
<link
rel="stylesheet"
Expand Down
4 changes: 3 additions & 1 deletion packages/app/pages/api/[...all].ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { createProxyMiddleware, fixRequestBody } from 'http-proxy-middleware';
import { getApiBasePath } from '@hyperdx/common-utils/dist/basePath';

const DEFAULT_SERVER_URL = `http://127.0.0.1:${process.env.HYPERDX_API_PORT}`;
const API_BASE_PATH = getApiBasePath();

export const config = {
api: {
Expand All @@ -14,7 +16,7 @@ export default (req: NextApiRequest, res: NextApiResponse) => {
const proxy = createProxyMiddleware({
changeOrigin: true,
// logger: console, // DEBUG
pathRewrite: { '^/api': '' },
pathRewrite: { '^/api': API_BASE_PATH },
target: process.env.SERVER_URL || DEFAULT_SERVER_URL,
autoRewrite: true,
// ...(IS_DEV && {
Expand Down
6 changes: 5 additions & 1 deletion packages/app/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import Router from 'next/router';
import type { HTTPError, Options, ResponsePromise } from 'ky';
import ky from 'ky-universal';
import { getApiBasePath } from '@hyperdx/common-utils/dist/basePath';
import type { Alert } from '@hyperdx/common-utils/dist/types';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
Expand Down Expand Up @@ -47,8 +48,11 @@ export function loginHook(request: Request, options: any, response: Response) {
}
}

// Get basePath from runtime environment
const getApiPrefix = () => getApiBasePath();

export const server = ky.create({
prefixUrl: '/api',
prefixUrl: getApiPrefix(),
credentials: 'include',
hooks: {
afterResponse: [loginHook],
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/clickhouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// please move app-specific functions elsewhere in the app
// ================================

import { getApiBasePath } from '@hyperdx/common-utils/dist/basePath';
import {
chSql,
ClickhouseClientOptions,
Expand All @@ -20,7 +21,7 @@ import { getLocalConnections } from '@/connection';
import api from './api';
import { DEFAULT_QUERY_TIMEOUT } from './defaults';

const PROXY_CLICKHOUSE_HOST = '/api/clickhouse-proxy';
const PROXY_CLICKHOUSE_HOST = `${getApiBasePath()}/clickhouse-proxy`;

export const getClickhouseClient = (
options: ClickhouseClientOptions = {},
Expand Down
78 changes: 78 additions & 0 deletions packages/common-utils/src/__tests__/basePath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
getApiBasePath,
getFrontendBasePath,
getOtelBasePath,
joinPath,
} from '../basePath';

describe('basePath utilities', () => {
describe('getFrontendBasePath', () => {
it('returns empty string if no env var', () => {
delete process.env.HYPERDX_BASE_PATH;
expect(getFrontendBasePath()).toBe('');
});

it('returns normalized path for valid input', () => {
process.env.HYPERDX_BASE_PATH = '/hyperdx';
expect(getFrontendBasePath()).toBe('/hyperdx');

process.env.HYPERDX_BASE_PATH = 'hyperdx/';
expect(getFrontendBasePath()).toBe('/hyperdx');

process.env.HYPERDX_BASE_PATH = '/hyperdx/';
expect(getFrontendBasePath()).toBe('/hyperdx');
});

it('returns empty for invalid paths', () => {
process.env.HYPERDX_BASE_PATH = '/../invalid';
expect(getFrontendBasePath()).toBe('');

process.env.HYPERDX_BASE_PATH = 'http://example.com';
expect(getFrontendBasePath()).toBe('');
});
});

describe('getApiBasePath', () => {
it('defaults to /api if no env var', () => {
delete process.env.HYPERDX_API_BASE_PATH;
expect(getApiBasePath()).toBe('/api');
});

it('uses env var if set', () => {
process.env.HYPERDX_API_BASE_PATH = '/hyperdx/api';
expect(getApiBasePath()).toBe('/hyperdx/api');
});

it('normalizes input', () => {
process.env.HYPERDX_API_BASE_PATH = 'hyperdx/api/';
expect(getApiBasePath()).toBe('/hyperdx/api');
});
});

describe('getOtelBasePath', () => {
it('returns empty if no env var', () => {
delete process.env.HYPERDX_OTEL_BASE_PATH;
expect(getOtelBasePath()).toBe('');
});

it('returns normalized path', () => {
process.env.HYPERDX_OTEL_BASE_PATH = '/hyperdx/otel';
expect(getOtelBasePath()).toBe('/hyperdx/otel');
});
});

describe('joinPath', () => {
it('joins empty base with relative', () => {
expect(joinPath('', '/test')).toBe('/test');
});

it('joins valid base and relative', () => {
expect(joinPath('/hyperdx', '/api')).toBe('/hyperdx/api');
expect(joinPath('/hyperdx', 'api')).toBe('/hyperdx/api');
});

it('normalizes joined path', () => {
expect(joinPath('/hyperdx/', '/api/')).toBe('/hyperdx/api');
});
});
});
Loading