Skip to content

Commit

Permalink
add detailed healthcheck for dependency statuses (#78)
Browse files Browse the repository at this point in the history
  • Loading branch information
bryce-fitzsimons authored Dec 2, 2024
1 parent 7d60355 commit efec2de
Show file tree
Hide file tree
Showing 8 changed files with 105 additions and 40 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ The GrowthBook Proxy repository is a mono-repo containing the following packages

### What's new

**Version 1.2.0**
- ARM support (via Depot)
- More detailed /healthcheck status

**Version 1.1.11**
- Guard against crashes when API server is down

Expand Down
2 changes: 1 addition & 1 deletion packages/apps/proxy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"node": ">=18"
},
"description": "GrowthBook proxy server for caching, realtime updates, telemetry, etc",
"version": "1.1.11",
"version": "1.2.0",
"main": "dist/app.js",
"license": "MIT",
"repository": {
Expand Down
38 changes: 4 additions & 34 deletions packages/apps/proxy/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import fs from "fs";
import path from "path";
import { Express } from "express";
import cors from "cors";
import { adminRouter } from "./controllers/adminController";
Expand All @@ -15,29 +13,12 @@ import {
import { Context, GrowthBookProxy } from "./types";
import logger, { initializeLogger } from "./services/logger";
import { initializeStickyBucketService } from "./services/stickyBucket";
import { healthRouter } from "./controllers/healthController";

export { Context, GrowthBookProxy, CacheEngine } from "./types";

let build: { sha: string; date: string };
function getBuild() {
if (!build) {
build = {
sha: "",
date: "",
};
const rootPath = path.join(__dirname, "..", "buildinfo");
if (fs.existsSync(path.join(rootPath, "SHA"))) {
build.sha = fs.readFileSync(path.join(rootPath, "SHA")).toString().trim();
}
if (fs.existsSync(path.join(rootPath, "DATE"))) {
build.date = fs
.readFileSync(path.join(rootPath, "DATE"))
.toString()
.trim();
}
}
return build;
}
const packageJson = require("../package.json");
export const version = (packageJson.version ?? "unknown") + "";

const defaultContext: Context = {
growthbookApiHost: "",
Expand Down Expand Up @@ -66,9 +47,6 @@ export const growthBookProxy = async (
app: Express,
context?: Partial<Context>,
): Promise<GrowthBookProxy> => {
const packageJson = require("../package.json");
const version = (packageJson.version ?? "unknown") + "";

const ctx: Context = { ...defaultContext, ...context };
app.locals.ctx = ctx;
if (!ctx.growthbookApiHost) console.error("GROWTHBOOK_API_HOST is missing");
Expand All @@ -85,15 +63,7 @@ export const growthBookProxy = async (

// set up handlers
ctx.enableCors && app.use(cors());
ctx.enableHealthCheck &&
app.get("/healthcheck", (req, res) => {
const build = getBuild();
res.status(200).json({
ok: true,
proxyVersion: version,
build,
});
});
ctx.enableHealthCheck && app.use("/healthcheck", healthRouter);
ctx.enableAdmin && logger.warn("Admin API is enabled");
ctx.enableAdmin && app.use("/admin", adminRouter);

Expand Down
62 changes: 62 additions & 0 deletions packages/apps/proxy/src/controllers/healthController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import express, { Request, Response } from "express";
import path from "path";
import fs from "fs";
import { Context, version } from "../app";
import { registrar } from "../services/registrar";
import { featuresCache } from "../services/cache";

let build: { sha: string; date: string };
function getBuild() {
if (!build) {
build = {
sha: "",
date: "",
};
const rootPath = path.join(__dirname, "../..", "buildinfo");
if (fs.existsSync(path.join(rootPath, "SHA"))) {
build.sha = fs.readFileSync(path.join(rootPath, "SHA")).toString().trim();
}
if (fs.existsSync(path.join(rootPath, "DATE"))) {
build.date = fs
.readFileSync(path.join(rootPath, "DATE"))
.toString()
.trim();
}
}
return build;
}

async function getChecks(ctx: Context) {
const checks: Record<string, any> = {
apiServer: "down",
registrar: registrar.status,
};
const cacheType = ctx?.cacheSettings?.cacheEngine || "memory";
checks[`cache:${cacheType}`] = await featuresCache?.getStatus?.() || "pending";

try {
const resp = await fetch(ctx.growthbookApiHost + "/healthcheck");
const data = await resp.json();
if (data?.healthy) checks.apiServer = "up";
} catch(e) {
console.error("healthcheck API sever error", e);
}
return checks;
}

const getHealthCheck = async (req: Request, res: Response) => {
const ctx = req.app.locals?.ctx;

const build = getBuild();
const checks = await getChecks(ctx);
res.status(200).json({
ok: true,
proxyVersion: version,
build,
checks,
});
};

export const healthRouter = express.Router();

healthRouter.get("/", getHealthCheck);
4 changes: 4 additions & 0 deletions packages/apps/proxy/src/services/cache/MemoryCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,8 @@ export class MemoryCache {
expiresOn: new Date(Date.now() + this.expiresTTL),
});
}

public async getStatus() {
return "up";
}
}
20 changes: 17 additions & 3 deletions packages/apps/proxy/src/services/cache/MongoCache.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Collection, MongoClient } from "mongodb";
import { Collection, Db, MongoClient } from "mongodb";
import logger from "../logger";
import { MemoryCache } from "./MemoryCache";
import { CacheEntry, CacheSettings } from "./index";

export class MongoCache {
private client: MongoClient | undefined;
private db: Db | undefined;
private collection: Collection | undefined;
private readonly memoryCacheClient: MemoryCache | undefined;
private readonly connectionUrl: string | undefined;
Expand Down Expand Up @@ -49,8 +50,8 @@ export class MongoCache {
logger.error(e, "Error connecting to mongo client");
});
await this.client.connect();
const db = this.client.db(this.databaseName);
this.collection = db.collection(this.collectionName);
this.db = this.client.db(this.databaseName);
this.collection = this.db.collection(this.collectionName);
await this.collection.createIndex({ key: 1 }, { unique: true });
await this.collection.createIndex(
{ "entry.expiresOn": 1 },
Expand Down Expand Up @@ -134,4 +135,17 @@ export class MongoCache {
public getClient() {
return this.client;
}

public async getStatus() {
if (!this.db) {
return "down";
}
try {
const stats = await this.db.stats({ maxTimeMS: 1000 });
return stats?.ok === 1 ? "up" : "down"
} catch (e) {
logger.error("Mongo getStatus", e);
return "down";
}
}
}
4 changes: 4 additions & 0 deletions packages/apps/proxy/src/services/cache/RedisCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,8 @@ export class RedisCache {
public getsubscriberClient() {
return this.subscriberClient;
}

public async getStatus() {
return this.client?.status;
}
}
11 changes: 9 additions & 2 deletions packages/apps/proxy/src/services/registrar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export class Registrar {
private getConnectionsPollingFrequency: number = 1000 * 60; // 1 min;
private multiOrg = false;

public status: "pending" | "connected" | "disconnected" | "unknown" = "pending";

public getConnection(apiKey: ApiKey): Connection | undefined {
return this.connections.get(apiKey);
}
Expand Down Expand Up @@ -139,20 +141,24 @@ export class Registrar {
logger.error(`connection polling error: status code is ${resp.status}`);
const text = await resp.text();
logger.error(text);
this.status = "disconnected";
return;
}
data = await resp.json();

} catch(e) {
logger.error(`connection polling error: API server unreachable`);
this.status = "disconnected";
}

if (!data?.connections) {
logger.error("connection polling error: no data");
this.status = "disconnected";
return;
}
if (Object.keys(data.connections).length === 0) {
logger.warn("connection polling: no connections found");
this.status = "unknown";
return;
}

Expand Down Expand Up @@ -198,6 +204,7 @@ export class Registrar {
`SDK connections count: ${Object.keys(newConnections).length}`,
);
}
this.status = "connected";
}
}

Expand All @@ -211,6 +218,7 @@ export const initializeRegistrar = async (context: Context) => {
for (const connection of context.connections) {
registrar.setConnection(connection.apiKey, connection);
}
registrar.status = "connected";
}

if (context.createConnectionsFromEnv) {
Expand All @@ -222,6 +230,7 @@ export const initializeRegistrar = async (context: Context) => {
registrar.setConnection(connection.apiKey, connection);
}
});
registrar.status = "connected";
}

if (context.pollForConnections) {
Expand All @@ -230,6 +239,4 @@ export const initializeRegistrar = async (context: Context) => {
}
await registrar.startConnectionPolling(context);
}

Object.freeze(registrar);
};

0 comments on commit efec2de

Please sign in to comment.