Skip to content

Commit

Permalink
Refactor Sentry integration: remove instrument.js, add SentryInstrume…
Browse files Browse the repository at this point in the history
…ntation class, and update server entry point
  • Loading branch information
jthoward64 committed Jan 17, 2025
1 parent 9aab6f0 commit 198bc27
Show file tree
Hide file tree
Showing 13 changed files with 683 additions and 624 deletions.
4 changes: 2 additions & 2 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@
"bs": "yarn run build && yarn run start",
"build": "tsc",
"check": "tsc --noEmit",
"dev": "tsc --watch & node --enable-source-maps --watch-path=./dist --watch-path=../common/dist ./dist/src/index.js",
"dev": "tsc --watch & node --import @sentry/node/preload --enable-source-maps --watch-path=./dist --watch-path=../common/dist ./dist/src/index.js",
"lint": "eslint .",
"migrate-and-start": "yarn dlx prisma migrate deploy && yarn run start",
"start": "node --enable-source-maps ./dist/src/index.js start",
"start": "node --import @sentry/node/preload --enable-source-maps ./dist/src/index.js start",
"repl": "node --enable-source-maps ./dist/src/index.js repl",
"test": "jest"
},
Expand Down
129 changes: 85 additions & 44 deletions packages/server/src/entry/server/Apollo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,77 +3,118 @@ import {
type ApolloServerPlugin,
type GraphQLRequestListener,
} from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer";
import { Service } from "@freshgum/typedi";
import express from "express";
import type { GraphQLFormattedError } from "graphql";

import type { GraphQLContext } from "#auth/context.js";
import { formatError } from "#lib/formatError.js";
import { SchemaService } from "#lib/graphqlSchema.js";
import { logger } from "#lib/logging/standardLogging.js";
import type { SyslogLevels } from "#lib/logging/SyslogLevels.js";
import { isDevelopmentToken, loggingLevelToken } from "#lib/typediTokens.js";

import { ExpressModule } from "./Express.js";

@Service([ExpressModule, loggingLevelToken, isDevelopmentToken])
@Service(
{
scope: "singleton",
},
[ExpressModule, SchemaService, loggingLevelToken, isDevelopmentToken]
)
export class ApolloModule {
private readonly apolloServer: ApolloServer<GraphQLContext>;
#apolloServer?: ApolloServer<GraphQLContext>;

constructor(
private readonly expressModule: ExpressModule,
private readonly schemaService: SchemaService,
private readonly loggingLevel: SyslogLevels,
private readonly isDevelopment: boolean
) {
const apolloServerPlugins = [
ApolloServerPluginDrainHttpServer({
httpServer: expressModule.httpServer,
}),
];
if (loggingLevel === "trace") {
logger.warning(
"Apollo Server is running in trace mode, make sure to limit the number of requests you make as the logs will get big quickly. TURN OFF SCHEMA REFRESH IN GRAPHQL PLAYGROUND."
);
apolloServerPlugins.push(basicLoggingPlugin);
}
) {}

public async init(): Promise<void> {
await this.schemaService.init();

// Set up Apollo Server
this.apolloServer = new ApolloServer<GraphQLContext>({
this.#apolloServer = new ApolloServer<GraphQLContext>({
introspection: true,
schema: graphqlSchema,
plugins: apolloServerPlugins,
schema: this.schemaService.schema,
plugins: [
ApolloServerPluginDrainHttpServer({
httpServer: this.expressModule.httpServer,
}),
],
logger: {
debug: logger.debug,
info: logger.info,
warn: logger.warning,
error: logger.error,
},
status400ForVariableCoercionErrors: true,
formatError(formatted, error) {
return formatError(formatted, error, isDevelopment);
},
formatError: this.formatError.bind(this),
});

if (this.loggingLevel === "trace") {
logger.warning(
"Apollo Server is running in trace mode, make sure to limit the number of requests you make as the logs will get big quickly. TURN OFF SCHEMA REFRESH IN GRAPHQL PLAYGROUND."
);
this.#apolloServer.addPlugin(this.basicLoggingPlugin);
}
}
}

const basicLoggingPlugin: ApolloServerPlugin = {
requestDidStart(requestContext) {
logger.trace(`graphQL request started:\n${requestContext.request.query}`, {
variables: requestContext.request.variables,
});
const listener: GraphQLRequestListener<GraphQLContext> = {
didEncounterErrors(requestContext) {
logger.info(
`an error happened in response to graphQL query: ${requestContext.request.query}`,
{ errors: requestContext.errors }
);
return Promise.resolve();
},
willSendResponse(requestContext) {
logger.trace("graphQL response sent", {
response: requestContext.response.body,
});
return Promise.resolve();
},
};
private formatError(formatted: GraphQLFormattedError, error: unknown) {
return formatError(formatted, error, this.isDevelopment);
}

return Promise.resolve(listener);
},
};
public async start(): Promise<void> {
await this.apolloServer.start();
const { authenticate } = await import("#lib/auth/context.js");

this.expressModule.app.use(
"/graphql",

express.json(),

expressMiddleware<GraphQLContext>(this.apolloServer, {
context: authenticate,
})
);
}

public get apolloServer(): ApolloServer<GraphQLContext> {
if (!this.#apolloServer) {
throw new Error("ApolloModule not started");
}
return this.#apolloServer;
}

private basicLoggingPlugin: ApolloServerPlugin = {
requestDidStart(requestContext) {
logger.trace(
`graphQL request started:\n${requestContext.request.query}`,
{
variables: requestContext.request.variables,
}
);
const listener: GraphQLRequestListener<GraphQLContext> = {
didEncounterErrors(requestContext) {
logger.info(
`an error happened in response to graphQL query: ${requestContext.request.query}`,
{ errors: requestContext.errors }
);
return Promise.resolve();
},
willSendResponse(requestContext) {
logger.trace("graphQL response sent", {
response: requestContext.response.body,
});
return Promise.resolve();
},
};

return Promise.resolve(listener);
},
};
}
189 changes: 181 additions & 8 deletions packages/server/src/entry/server/Express.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,190 @@
import http from "node:http";

import { Service } from "@freshgum/typedi";
import { Container, Service } from "@freshgum/typedi";
import { setupExpressErrorHandler } from "@sentry/node";
import { ConcreteError, ErrorCode } from "@ukdanceblue/common/error";
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";

@Service([])
import { formatError } from "#lib/formatError.js";
import { logger } from "#lib/logging/standardLogging.js";
import type { SyslogLevels } from "#lib/logging/SyslogLevels.js";
import {
applicationPortToken,
cookieSecretToken,
isDevelopmentToken,
loggingLevelToken,
} from "#lib/typediTokens.js";
import { SessionRepository } from "#repositories/Session.js";

@Service(
{
scope: "singleton",
},
[
SessionRepository,
applicationPortToken,
loggingLevelToken,
isDevelopmentToken,
cookieSecretToken,
]
)
export class ExpressModule {
public readonly app: express.Application;
public readonly httpServer: http.Server;
#app?: express.Application;
#httpServer?: http.Server;
#middlewaresLoaded = false;

constructor(
private readonly sessionRepository: SessionRepository,
private readonly applicationPort: number,
private readonly loggingLevel: SyslogLevels,
private readonly isDevelopment: boolean,
private readonly cookieSecret: string
) {}

async init(): Promise<void> {
this.#app = express();
this.#app.set("trust proxy", true);

this.#httpServer = http.createServer(this.app);

// eslint-disable-next-line unicorn/no-useless-promise-resolve-reject
return Promise.resolve();
}

public startMiddlewares() {
if (this.loggingLevel === "trace") {
this.app.use((req, _res, next) => {
logger.trace("request received", {
method: req.method,
url: req.url,
});
next();
});
}
this.app.use((req, _, next) => {
req.getService = Container.get.bind(Container);
next();
});
this.app.use(
cors({
credentials: true,
origin: this.isDevelopment
? [/^https:\/\/(\w+\.)?danceblue\.org$/, /^http:\/\/localhost:\d+$/]
: /^https:\/\/(\w+\.)?danceblue\.org$/,
})
);
this.app.use(cookieParser(this.cookieSecret));
this.app.use(this.sessionRepository.expressMiddleware);

this.#middlewaresLoaded = true;
}

public async startRoutes() {
if (!this.middlewaresLoaded) {
throw new Error("Middlewares not loaded");
}

const apiRouter = express.Router();

const { default: authApiRouter } = await import(
"#routes/api/auth/index.js"
);
const { default: eventsApiRouter } = await import(
"#routes/api/events/index.js"
);
const { default: healthCheckRouter } = await import(
"#routes/api/healthcheck/index.js"
);
const { default: fileRouter } = await import("#routes/api/file/index.js");
const { default: uploadRouter } = await import(
"#routes/api/upload/index.js"
);

Container.get(authApiRouter).mount(apiRouter);
Container.get(eventsApiRouter).mount(apiRouter);
Container.get(healthCheckRouter).mount(apiRouter);
Container.get(fileRouter).mount(apiRouter);
Container.get(uploadRouter).mount(apiRouter);

this.app.use("/api", apiRouter);
}

public startErrorHandlers() {
setupExpressErrorHandler(this.app, {
shouldHandleError(error) {
if (
error instanceof ConcreteError &&
[
ErrorCode.AccessControlError,
ErrorCode.AuthorizationRuleFailed,
ErrorCode.NotFound,
ErrorCode.Unauthenticated,
].includes(error.tag)
) {
return false;
}
return true;
},
});
this.app.use(this.expressErrorHandler.bind(this));
}

public async start(): Promise<void> {
await new Promise<void>((resolve, reject) => {
this.httpServer.on("error", reject);
this.httpServer.listen(this.applicationPort, () => {
this.httpServer.off("error", reject);
resolve();
});
});
}

public get app(): express.Application {
if (!this.#app) {
throw new Error("ExpressModule not started");
}
return this.#app;
}
public get httpServer(): http.Server {
if (!this.#httpServer) {
throw new Error("ExpressModule not started");
}
return this.#httpServer;
}
public get middlewaresLoaded(): boolean {
return this.#middlewaresLoaded;
}

constructor() {
this.app = express();
this.app.set("trust proxy", true);
private expressErrorHandler(
err: unknown,
_r: express.Request,
res: express.Response,
next: express.NextFunction
) {
if (res.headersSent) {
return next(err);
}

this.httpServer = http.createServer(this.app);
const formatted = formatError(
err instanceof Error
? err
: err instanceof ConcreteError
? err.graphQlError
: new Error(String(err)),
err,
this.isDevelopment
);
if (
formatted.extensions &&
"code" in formatted.extensions &&
formatted.extensions.code === ErrorCode.Unauthenticated.description
) {
res.status(401).json(formatted);
} else {
logger.error("Unhandled error in Express", { error: formatted });
res.status(500).json(formatted);
}
}
}
Loading

0 comments on commit 198bc27

Please sign in to comment.