Skip to content

Commit fba78a9

Browse files
committed
feat: adding OpenReplay to long-term improve the user experience
1 parent 95fa998 commit fba78a9

15 files changed

Lines changed: 284 additions & 36 deletions

File tree

Dockerfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
FROM node:22-alpine AS build
33

44
ARG GITHUB_SHA
5+
ARG VITE_OPENREPLAY_PROJECT_KEY=3cwetvApbpUmvIOlsktv
6+
ARG VITE_OPENREPLAY_INGEST_POINT=https://hexo.did.science/api/ingest/
57

68
ENV PNPM_HOME="/pnpm"
79
ENV PATH="$PNPM_HOME:$PATH"
10+
ENV VITE_OPENREPLAY_PROJECT_KEY="$VITE_OPENREPLAY_PROJECT_KEY"
11+
ENV VITE_OPENREPLAY_INGEST_POINT="$VITE_OPENREPLAY_INGEST_POINT"
812

913
RUN apk add git
1014
RUN corepack enable
@@ -50,4 +54,4 @@ RUN cd packages/backend && pnpm install --frozen-lockfile
5054

5155
EXPOSE 3001
5256

53-
CMD ["node", "packages/backend/dist/server.cjs"]
57+
CMD ["node", "packages/backend/dist/server.cjs"]

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ docker run -d \
118118
ih3t:latest
119119
```
120120

121-
The Docker build context excludes `.env` files, so runtime configuration must be provided with `--env-file` or `-e` flags when the container starts.
121+
The Docker build context excludes `.env` files, so backend runtime configuration must be provided with `--env-file` or `-e` flags when the container starts. Frontend Vite variables such as the OpenReplay project key and ingest point are build-time settings in this Docker setup, so they must be overridden with `--build-arg` when building the image.
122122

123123
## AI Use
124124

packages/backend/src/network/clientInfo.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Socket } from 'socket.io';
44

55
export type RequestClientInfo = {
66
deviceId: string;
7+
openReplaySessionId: string | null;
78
ip: string;
89
userAgent: string;
910
origin: string;
@@ -51,6 +52,7 @@ export function getRequestClientInfo(request: Request): RequestClientInfo {
5152

5253
return {
5354
deviceId,
55+
openReplaySessionId: request.get(`x-openreplay-sessionid`) ?? null,
5456
ip: request.ip ?? ``,
5557
userAgent: request.get(`user-agent`) ?? ``,
5658
origin: request.get(`origin`) ?? ``,
@@ -66,6 +68,7 @@ export function getSocketClientInfo(socket: Socket<ClientToServerEvents, ServerT
6668
versionHash,
6769

6870
socketId: socket.id,
71+
openReplaySessionId: null,
6972
ip: socket.handshake.address ?? ``,
7073

7174
userAgent: getHeaderValue(socket.handshake.headers[`user-agent`]) ?? ``,

packages/backend/src/network/cors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export class CorsConfiguration {
4646
methods: [
4747
`GET`, `POST`, `PATCH`, `PUT`, `OPTIONS`, `DELETE`,
4848
],
49-
allowedHeaders: [`Content-Type`, `X-Device-Id`],
49+
allowedHeaders: [`Content-Type`, `X-Device-Id`, `X-OpenReplay-SessionId`],
5050
credentials: true,
5151
};
5252
}

packages/backend/src/network/createHttpApp.ts

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,30 @@ import { z } from 'zod';
1010
import { AuthService } from '../auth/authService';
1111
import { ServerConfig } from '../config/serverConfig';
1212
import { ROOT_LOGGER } from '../logger';
13+
import { getRequestClientInfo } from './clientInfo';
1314
import { CorsConfiguration } from './cors';
1415
import { FrontendSsrRenderer } from './frontendSsr';
15-
import { ApiQueryService } from './rest/apiQueryService';
16+
import { ApiQueryService, ApiRequestError } from './rest/apiQueryService';
1617
import { ApiRouter } from './rest/createApiRouter';
1718

19+
type HttpErrorContext = {
20+
err?: Error;
21+
issues?: z.ZodIssue[];
22+
responseBody?: unknown;
23+
};
24+
25+
function getHttpErrorContext(response: express.Response): HttpErrorContext | null {
26+
return (response.locals as { httpErrorContext?: HttpErrorContext }).httpErrorContext ?? null;
27+
}
28+
29+
function setHttpErrorContext(response: express.Response, context: Partial<HttpErrorContext>): void {
30+
const locals = response.locals as { httpErrorContext?: HttpErrorContext };
31+
locals.httpErrorContext = {
32+
...locals.httpErrorContext,
33+
...context,
34+
};
35+
}
36+
1837
@injectable()
1938
export class HttpApplication {
2039
readonly app: express.Application;
@@ -47,42 +66,86 @@ export class HttpApplication {
4766
app.use((req, res, next) => {
4867
const requestId = randomUUID();
4968
const startedAt = process.hrtime.bigint();
69+
const client = getRequestClientInfo(req);
70+
const originalJson = res.json.bind(res);
5071
const requestLogger = logger.child({
5172
requestId,
5273
method: req.method,
5374
path: req.originalUrl,
5475
remoteAddress: req.ip,
76+
deviceId: client.deviceId,
77+
openReplaySessionId: client.openReplaySessionId,
5578
});
5679

80+
res.json = ((body: unknown) => {
81+
if (res.statusCode >= 400) {
82+
setHttpErrorContext(res, { responseBody: body });
83+
}
84+
85+
return originalJson(body);
86+
}) as typeof res.json;
87+
5788
res.on(`finish`, () => {
5889
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
59-
requestLogger.trace({
60-
event: `http.request.completed`,
90+
const event = res.statusCode >= 400 ? `http.request.failed` : `http.request.completed`;
91+
const logContext = {
92+
event,
6193
statusCode: res.statusCode,
6294
durationMs: Number(durationMs.toFixed(3)),
6395
contentLength: res.getHeader(`content-length`) ?? null,
6496
userAgent: req.get(`user-agent`) ?? null,
65-
}, `HTTP request completed`);
97+
...getHttpErrorContext(res),
98+
};
99+
100+
if (res.statusCode >= 500) {
101+
requestLogger.error(logContext, `HTTP request failed`);
102+
return;
103+
}
104+
105+
if (res.statusCode >= 400) {
106+
requestLogger.warn(logContext, `HTTP request failed`);
107+
return;
108+
}
109+
110+
requestLogger.trace(logContext, `HTTP request completed`);
66111
});
67112

68113
next();
69114
});
70115

71116
app.use(`/auth`, express.urlencoded({ extended: false }), express.json(), authService.handler);
72117
app.use(`/api`, apiRouter.router);
73-
app.use((error: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
118+
119+
if (existsSync(this.frontendDistPath)) {
120+
app.use(express.static(this.frontendDistPath, { index: false }));
121+
app.get(/^(?!\/api(?:\/|$)|\/socket\.io(?:\/|$)).*/, async (req, res) => {
122+
const joinRedirectUrl = this.resolveJoinRedirectUrl(req);
123+
if (joinRedirectUrl) {
124+
res.redirect(302, joinRedirectUrl);
125+
return;
126+
}
127+
128+
const archiveRedirectUrl = this.resolveArchiveRedirectUrl(req);
129+
if (archiveRedirectUrl) {
130+
res.redirect(302, archiveRedirectUrl);
131+
return;
132+
}
133+
134+
const html = await this.frontendSsrRenderer.render(req);
135+
res.type(`html`).send(html);
136+
});
137+
}
138+
139+
app.use((error: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
74140
if (!(error instanceof z.ZodError)) {
75141
next(error);
76142
return;
77143
}
78144

79-
logger.warn({
145+
setHttpErrorContext(res, {
80146
err: error,
81-
event: `http.request.invalid`,
82-
method: req.method,
83-
path: req.originalUrl,
84147
issues: error.issues,
85-
}, `HTTP request validation failed`);
148+
});
86149

87150
const friendlyMessage = error.issues
88151
.map((issue) => {
@@ -96,26 +159,22 @@ export class HttpApplication {
96159
issues: error.issues,
97160
});
98161
});
162+
app.use((error: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
163+
if (res.headersSent) {
164+
next(error);
165+
return;
166+
}
99167

100-
if (existsSync(this.frontendDistPath)) {
101-
app.use(express.static(this.frontendDistPath, { index: false }));
102-
app.get(/^(?!\/api(?:\/|$)|\/socket\.io(?:\/|$)).*/, async (req, res) => {
103-
const joinRedirectUrl = this.resolveJoinRedirectUrl(req);
104-
if (joinRedirectUrl) {
105-
res.redirect(302, joinRedirectUrl);
106-
return;
107-
}
108-
109-
const archiveRedirectUrl = this.resolveArchiveRedirectUrl(req);
110-
if (archiveRedirectUrl) {
111-
res.redirect(302, archiveRedirectUrl);
112-
return;
113-
}
168+
if (error instanceof ApiRequestError) {
169+
setHttpErrorContext(res, { err: error });
170+
res.status(error.statusCode).json({ error: error.message });
171+
return;
172+
}
114173

115-
const html = await this.frontendSsrRenderer.render(req);
116-
res.type(`html`).send(html);
117-
});
118-
}
174+
const normalizedError = error instanceof Error ? error : new Error(`Unexpected server error`);
175+
setHttpErrorContext(res, { err: normalizedError });
176+
res.status(500).json({ error: `Internal server error.` });
177+
});
119178

120179
this.app = app;
121180
}

packages/backend/src/tournament/tournamentService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { buildSwissRoundMatches, calculateSwissStandings } from './tournamentSwi
4141

4242
const kTournamentSystemClient: RequestClientInfo = {
4343
deviceId: `tournament-system`,
44+
openReplaySessionId: null,
4445
ip: ``,
4546
userAgent: `tournament-system`,
4647
origin: ``,

packages/frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"dependencies": {
2020
"@ih3t/bot-worker": "workspace:*",
2121
"@ih3t/shared": "workspace:*",
22+
"@openreplay/tracker": "^18.0.6",
2223
"@tanstack/react-query": "^5.91.2",
2324
"@tanstack/react-query-devtools": "^5.95.2",
2425
"clsx": "^2.1.1",

packages/frontend/src/App.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useEffect } from 'react';
44
import { RouterProvider } from 'react-router';
55

66
import AppErrorBoundary from './components/AppErrorBoundary';
7+
import { trackOpenReplayUser } from './openReplay';
8+
import { useQueryAccount } from './query/accountClient';
79
import { clearHydrationRenderPassFlag, useRenderMode } from './ssrState';
810

911
type AppProps = {
@@ -12,6 +14,25 @@ type AppProps = {
1214
dehydratedState?: DehydratedState
1315
};
1416

17+
function OpenReplayUserSync() {
18+
const renderMode = useRenderMode();
19+
const accountQuery = useQueryAccount({ enabled: renderMode !== `ssr` });
20+
const account = accountQuery.data?.user ?? null;
21+
22+
useEffect(() => {
23+
if (!account) {
24+
return;
25+
}
26+
27+
trackOpenReplayUser({
28+
id: account.id,
29+
username: account.username,
30+
});
31+
}, [account]);
32+
33+
return null;
34+
}
35+
1536
function App({ router, queryClient, dehydratedState }: Readonly<AppProps>) {
1637
const renderMode = useRenderMode();
1738

@@ -31,6 +52,7 @@ function App({ router, queryClient, dehydratedState }: Readonly<AppProps>) {
3152

3253
<QueryClientProvider client={queryClient}>
3354
{renderMode === `normal` && <ReactQueryDevtools />}
55+
<OpenReplayUserSync />
3456

3557
<HydrationBoundary state={dehydratedState}>
3658
<RouterProvider router={router} />
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import './index.css';
22
import 'react-toastify/dist/ReactToastify.css';
33

4+
import { initializeOpenReplay } from './openReplay';
45
import { installSoundEffects } from './soundEffects';
56

67
installSoundEffects();
8+
void initializeOpenReplay();
79

810
const start = performance.now();
911
import(`./main`).then(() => {
1012
console.log(`App loaded in %dms`, performance.now() - start);
1113
}).catch(error => {
1214
console.error(error);
1315
alert(`Failed to load app.\nPlease reload!`);
14-
});
16+
});

0 commit comments

Comments
 (0)