add sentry support to backend and webapp (#223)

* add sentry to web app

* set sentry environemnt from env var

* add sentry env replace logic in docker container

* wip add backend sentry

* add sentry to backend

* move dns to env var

* remove test exception
This commit is contained in:
Michael Sukkarieh 2025-03-01 19:21:17 -08:00 committed by GitHub
parent 85c21a2519
commit a93ee6527c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1180 additions and 32 deletions

View file

@ -48,6 +48,8 @@ ARG NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED=BAKED_NEXT_PUBLIC_SOURCEBOT_TELEMET
ARG NEXT_PUBLIC_SOURCEBOT_VERSION=BAKED_NEXT_PUBLIC_SOURCEBOT_VERSION
ENV NEXT_PUBLIC_POSTHOG_PAPIK=BAKED_NEXT_PUBLIC_POSTHOG_PAPIK
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=BAKED_NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=BAKED_NEXT_PUBLIC_SENTRY_ENVIRONMENT
ENV NEXT_PUBLIC_SENTRY_WEBAPP_DSN=BAKED_NEXT_PUBLIC_SENTRY_WEBAPP_DSN
# @nocheckin: This was interfering with the the `matcher` regex in middleware.ts,
# causing regular expressions parsing errors when making a request. It's unclear

View file

@ -123,6 +123,12 @@ echo "{\"version\": \"$SOURCEBOT_VERSION\", \"install_id\": \"$SOURCEBOT_INSTALL
# Always infer NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
export NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="$STRIPE_PUBLISHABLE_KEY"
# Always infer NEXT_PUBLIC_SENTRY_ENVIRONMENT
export NEXT_PUBLIC_SENTRY_ENVIRONMENT="$SENTRY_ENVIRONMENT"
# Always infer NEXT_PUBLIC_SENTRY_WEBAPP_DSN
export NEXT_PUBLIC_SENTRY_WEBAPP_DSN="$SENTRY_WEBAPP_DSN"
# Iterate over all .js files in .next & public, making substitutions for the `BAKED_` sentinal values
# with their actual desired runtime value.
find /app/packages/web/public /app/packages/web/.next -type f -name "*.js" |
@ -131,6 +137,8 @@ echo "{\"version\": \"$SOURCEBOT_VERSION\", \"install_id\": \"$SOURCEBOT_INSTALL
sed -i "s|BAKED_NEXT_PUBLIC_SOURCEBOT_VERSION|${NEXT_PUBLIC_SOURCEBOT_VERSION}|g" "$file"
sed -i "s|BAKED_NEXT_PUBLIC_POSTHOG_PAPIK|${NEXT_PUBLIC_POSTHOG_PAPIK}|g" "$file"
sed -i "s|BAKED_NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY|${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}|g" "$file"
sed -i "s|BAKED_NEXT_PUBLIC_SENTRY_ENVIRONMENT|${NEXT_PUBLIC_SENTRY_ENVIRONMENT}|g" "$file"
sed -i "s|BAKED_NEXT_PUBLIC_SENTRY_WEBAPP_DSN|${NEXT_PUBLIC_SENTRY_WEBAPP_DSN}|g" "$file"
done
}

View file

@ -1,2 +1,4 @@
dist/
!.env
# Sentry Config File
.sentryclirc

View file

@ -7,8 +7,9 @@
"scripts": {
"dev:watch": "tsc-watch --preserveWatchOutput --onSuccess \"yarn dev --cacheDir ../../.sourcebot\"",
"dev": "export PATH=\"$PWD/../../bin:$PATH\" && export CTAGS_COMMAND=ctags && node ./dist/index.js",
"build": "tsc",
"test": "vitest --config ./vitest.config.ts"
"build": "tsc && yarn sentry:sourcemaps",
"test": "vitest --config ./vitest.config.ts",
"sentry:sourcemaps": "sentry-cli sourcemaps inject --org sourcebot --project backend ./dist && sentry-cli sourcemaps upload --org sourcebot --project backend ./dist"
},
"devDependencies": {
"@types/argparse": "^2.0.16",
@ -23,6 +24,9 @@
"dependencies": {
"@gitbeaker/rest": "^40.5.1",
"@octokit/rest": "^21.0.2",
"@sentry/cli": "^2.42.2",
"@sentry/node": "^9.3.0",
"@sentry/profiling-node": "^9.3.0",
"@sourcebot/crypto": "^0.1.0",
"@sourcebot/db": "^0.1.0",
"@sourcebot/error": "^0.1.0",

View file

@ -8,6 +8,7 @@ import { Redis } from 'ioredis';
import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig } from "./repoCompileUtils.js";
import { BackendError, BackendException } from "@sourcebot/error";
import { captureEvent } from "./posthog.js";
import * as Sentry from "@sentry/node";
interface IConnectionManager {
scheduleConnectionSync: (connection: Connection) => Promise<void>;
@ -94,9 +95,11 @@ export class ConnectionManager implements IConnectionManager {
});
if (!connection) {
throw new BackendException(BackendError.CONNECTION_SYNC_CONNECTION_NOT_FOUND, {
const e = new BackendException(BackendError.CONNECTION_SYNC_CONNECTION_NOT_FOUND, {
message: `Connection ${job.data.connectionId} not found`,
});
Sentry.captureException(e);
throw e;
}
// Reset the syncStatusMetadata to an empty object at the start of the sync job
@ -146,6 +149,8 @@ export class ConnectionManager implements IConnectionManager {
})();
} catch (err) {
this.logger.error(`Failed to compile repo data for connection ${job.data.connectionId}: ${err}`);
Sentry.captureException(err);
if (err instanceof BackendException) {
throw err;
} else {

View file

@ -1,3 +1,5 @@
import * as Sentry from "@sentry/node";
type ValidResult<T> = {
type: 'valid';
data: T[];
@ -39,6 +41,7 @@ export function processPromiseResults<T>(
export function throwIfAnyFailed<T>(results: PromiseSettledResult<T>[]) {
const failedResult = results.find(result => result.status === 'rejected');
if (failedResult) {
Sentry.captureException(failedResult.reason);
throw failedResult.reason;
}
}

View file

@ -1,8 +1,11 @@
import dotenv from 'dotenv';
import * as Sentry from "@sentry/node";
export const getEnv = (env: string | undefined, defaultValue?: string, required?: boolean) => {
if (required && !env && !defaultValue) {
throw new Error(`Missing required environment variable: ${env}`);
const e = new Error(`Missing required environment variable: ${env}`);
Sentry.captureException(e);
throw e;
}
return env ?? defaultValue;
@ -37,3 +40,6 @@ export const FALLBACK_GITEA_TOKEN = getEnv(process.env.FALLBACK_GITEA_TOKEN);
export const INDEX_CONCURRENCY_MULTIPLE = getEnv(process.env.INDEX_CONCURRENCY_MULTIPLE);
export const REDIS_URL = getEnv(process.env.REDIS_URL, 'redis://localhost:6379')!;
export const SENTRY_BACKEND_DSN = getEnv(process.env.SENTRY_BACKEND_DSN);
export const SENTRY_ENVIRONMENT = getEnv(process.env.SENTRY_ENVIRONMENT, 'unknown')!;

View file

@ -5,6 +5,7 @@ import micromatch from "micromatch";
import { measure, fetchWithRetry } from './utils.js';
import { BackendError } from '@sourcebot/error';
import { BackendException } from '@sourcebot/error';
import * as Sentry from "@sentry/node";
// https://gerrit-review.googlesource.com/Documentation/rest-api.html
interface GerritProjects {
@ -40,6 +41,7 @@ export const getGerritReposFromConfig = async (config: GerritConfig): Promise<Ge
const fetchFn = () => fetchAllProjects(url);
return fetchWithRetry(fetchFn, `projects from ${url}`, logger);
} catch (err) {
Sentry.captureException(err);
if (err instanceof BackendException) {
throw err;
}
@ -50,7 +52,9 @@ export const getGerritReposFromConfig = async (config: GerritConfig): Promise<Ge
});
if (!projects) {
throw new Error(`Failed to fetch projects from ${url}`);
const e = new Error(`Failed to fetch projects from ${url}`);
Sentry.captureException(e);
throw e;
}
// exclude "All-Projects" and "All-Users" projects
@ -89,11 +93,14 @@ const fetchAllProjects = async (url: string): Promise<GerritProject[]> => {
response = await fetch(endpointWithParams);
if (!response.ok) {
console.log(`Failed to fetch projects from Gerrit at ${endpointWithParams} with status ${response.status}`);
throw new BackendException(BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS, {
const e = new BackendException(BackendError.CONNECTION_SYNC_FAILED_TO_FETCH_GERRIT_PROJECTS, {
status: response.status,
});
Sentry.captureException(e);
throw e;
}
} catch (err) {
Sentry.captureException(err);
if (err instanceof BackendException) {
throw err;
}

View file

@ -7,6 +7,8 @@ import micromatch from 'micromatch';
import { PrismaClient } from '@sourcebot/db';
import { FALLBACK_GITEA_TOKEN } from './environment.js';
import { processPromiseResults, throwIfAnyFailed } from './connectionUtils.js';
import * as Sentry from "@sentry/node";
const logger = createLogger('Gitea');
export const getGiteaReposFromConfig = async (config: GiteaConnectionConfig, orgId: number, db: PrismaClient) => {
@ -132,6 +134,8 @@ const getReposOwnedByUsers = async <T>(users: string[], api: Api<T>) => {
data
};
} catch (e: any) {
Sentry.captureException(e);
if (e?.status === 404) {
logger.error(`User ${user} not found or no access`);
return {
@ -170,6 +174,8 @@ const getReposForOrgs = async <T>(orgs: string[], api: Api<T>) => {
data
};
} catch (e: any) {
Sentry.captureException(e);
if (e?.status === 404) {
logger.error(`Organization ${org} not found or no access`);
return {
@ -206,6 +212,8 @@ const getRepos = async <T>(repos: string[], api: Api<T>) => {
data: [response.data]
};
} catch (e: any) {
Sentry.captureException(e);
if (e?.status === 404) {
logger.error(`Repository ${repo} not found or no access`);
return {
@ -234,7 +242,9 @@ const paginate = async <T>(request: (page: number) => Promise<HttpResponse<T[],
const totalCountString = result.headers.get('x-total-count');
if (!totalCountString) {
throw new Error("Header 'x-total-count' not found");
const e = new Error("Header 'x-total-count' not found");
Sentry.captureException(e);
throw e;
}
const totalCount = parseInt(totalCountString);

View file

@ -7,6 +7,8 @@ import { PrismaClient } from "@sourcebot/db";
import { FALLBACK_GITHUB_TOKEN } from "./environment.js";
import { BackendException, BackendError } from "@sourcebot/error";
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
import * as Sentry from "@sentry/node";
const logger = createLogger("GitHub");
export type OctokitRepository = {
@ -53,15 +55,21 @@ export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, o
try {
await octokit.rest.users.getAuthenticated();
} catch (error) {
Sentry.captureException(error);
if (isHttpError(error, 401)) {
throw new BackendException(BackendError.CONNECTION_SYNC_INVALID_TOKEN, {
const e = new BackendException(BackendError.CONNECTION_SYNC_INVALID_TOKEN, {
secretKey,
});
Sentry.captureException(e);
throw e;
}
throw new BackendException(BackendError.CONNECTION_SYNC_SYSTEM_ERROR, {
const e = new BackendException(BackendError.CONNECTION_SYNC_SYSTEM_ERROR, {
message: `Failed to authenticate with GitHub`,
});
Sentry.captureException(e);
throw e;
}
}
@ -239,6 +247,8 @@ const getReposOwnedByUsers = async (users: string[], isAuthenticated: boolean, o
data
};
} catch (error) {
Sentry.captureException(error);
if (isHttpError(error, 404)) {
logger.error(`User ${user} not found or no access`);
return {
@ -282,6 +292,8 @@ const getReposForOrgs = async (orgs: string[], octokit: Octokit, signal: AbortSi
data
};
} catch (error) {
Sentry.captureException(error);
if (isHttpError(error, 404)) {
logger.error(`Organization ${org} not found or no access`);
return {
@ -327,6 +339,8 @@ const getRepos = async (repoList: string[], octokit: Octokit, signal: AbortSigna
};
} catch (error) {
Sentry.captureException(error);
if (isHttpError(error, 404)) {
logger.error(`Repository ${repo} not found or no access`);
return {

View file

@ -6,6 +6,8 @@ import { getTokenFromConfig, measure, fetchWithRetry } from "./utils.js";
import { PrismaClient } from "@sourcebot/db";
import { FALLBACK_GITLAB_TOKEN } from "./environment.js";
import { processPromiseResults, throwIfAnyFailed } from "./connectionUtils.js";
import * as Sentry from "@sentry/node";
const logger = createLogger("GitLab");
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
@ -47,6 +49,7 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
logger.debug(`Found ${_projects.length} projects in ${durationMs}ms.`);
allRepos = allRepos.concat(_projects);
} catch (e) {
Sentry.captureException(e);
logger.error(`Failed to fetch all projects visible in ${config.url}.`, e);
throw e;
}
@ -72,6 +75,8 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
data
};
} catch (e: any) {
Sentry.captureException(e);
const status = e?.cause?.response?.status;
if (status === 404) {
logger.error(`Group ${group} not found or no access`);
@ -106,6 +111,8 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
data
};
} catch (e: any) {
Sentry.captureException(e);
const status = e?.cause?.response?.status;
if (status === 404) {
logger.error(`User ${user} not found or no access`);
@ -138,6 +145,8 @@ export const getGitLabReposFromConfig = async (config: GitlabConnectionConfig, o
data: [data]
};
} catch (e: any) {
Sentry.captureException(e);
const status = e?.cause?.response?.status;
if (status === 404) {

View file

@ -1,3 +1,6 @@
import "./instrument.js";
import * as Sentry from "@sentry/node";
import { ArgumentParser } from "argparse";
import { existsSync } from 'fs';
import { mkdir } from 'fs/promises';
@ -48,6 +51,8 @@ main(prisma, context)
})
.catch(async (e) => {
console.error(e);
Sentry.captureException(e);
await prisma.$disconnect();
process.exit(1);
})

View file

@ -0,0 +1,8 @@
import * as Sentry from "@sentry/node";
import { SOURCEBOT_VERSION, SENTRY_BACKEND_DSN, SENTRY_ENVIRONMENT } from "./environment.js";
Sentry.init({
dsn: SENTRY_BACKEND_DSN,
release: SOURCEBOT_VERSION,
environment: SENTRY_ENVIRONMENT,
});

View file

@ -10,7 +10,7 @@ import { existsSync, readdirSync, promises } from 'fs';
import { indexGitRepository } from "./zoekt.js";
import os from 'os';
import { PromClient } from './promClient.js';
import * as Sentry from "@sentry/node";
interface IRepoManager {
blockingPollLoop: () => void;
dispose: () => void;
@ -258,7 +258,9 @@ export class RepoManager implements IRepoManager {
});
if (!existingRepo) {
this.logger.error(`Repo ${repo.id} not found`);
throw new Error(`Repo ${repo.id} not found`);
const e = new Error(`Repo ${repo.id} not found`);
Sentry.captureException(e);
throw e;
}
const repoAlreadyInIndexingState = existingRepo.repoIndexingStatus === RepoIndexingStatus.INDEXING;
@ -287,6 +289,8 @@ export class RepoManager implements IRepoManager {
stats = await this.syncGitRepository(repo, repoAlreadyInIndexingState);
break;
} catch (error) {
Sentry.captureException(error);
attempts++;
this.promClient.repoIndexingReattemptsTotal.inc();
if (attempts === maxAttempts) {

View file

@ -5,6 +5,7 @@ import { PrismaClient, Repo } from "@sourcebot/db";
import { decrypt } from "@sourcebot/crypto";
import { Token } from "@sourcebot/schemas/v3/shared.type";
import { BackendException, BackendError } from "@sourcebot/error";
import * as Sentry from "@sentry/node";
export const measure = async <T>(cb: () => Promise<T>) => {
const start = Date.now();
@ -22,9 +23,11 @@ export const marshalBool = (value?: boolean) => {
export const getTokenFromConfig = async (token: Token, orgId: number, db?: PrismaClient) => {
if (!db) {
throw new BackendException(BackendError.CONNECTION_SYNC_SYSTEM_ERROR, {
const e = new BackendException(BackendError.CONNECTION_SYNC_SYSTEM_ERROR, {
message: `No database connection provided.`,
});
Sentry.captureException(e);
throw e;
}
const secretKey = token.secret;
@ -38,9 +41,11 @@ export const getTokenFromConfig = async (token: Token, orgId: number, db?: Prism
});
if (!secret) {
throw new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, {
const e = new BackendException(BackendError.CONNECTION_SYNC_SECRET_DNE, {
message: `Secret with key ${secretKey} not found for org ${orgId}`,
});
Sentry.captureException(e);
throw e;
}
const decryptedSecret = decrypt(secret.iv, secret.encryptedValue);
@ -104,6 +109,8 @@ export const fetchWithRetry = async <T>(
try {
return await fetchFn();
} catch (e: any) {
Sentry.captureException(e);
attempts++;
if ((e.status === 403 || e.status === 429 || e.status === 443) && attempts < maxAttempts) {
const computedWaitTime = 3000 * Math.pow(2, attempts - 1);

View file

@ -20,7 +20,12 @@
"lib": ["ES2023"],
"strict": true,
"sourceMap": true,
"inlineSources": true
"inlineSources": true,
// Set `sourceRoot` to "/" to strip the build path prefix
// from generated source code references.
// This improves issue grouping in Sentry.
"sourceRoot": "/"
},
"include": ["src/index.ts"],
"exclude": ["node_modules"]

View file

@ -40,3 +40,5 @@ next-env.d.ts
# End of https://www.toptal.com/developers/gitignore/api/nextjs
!.env
# Sentry Config File
.env.sentry-build-plugin

View file

@ -1,3 +1,4 @@
import {withSentryConfig} from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
@ -41,4 +42,39 @@ const nextConfig = {
// } : {})
};
export default nextConfig;
export default withSentryConfig(nextConfig, {
// For all available options, see:
// https://www.npmjs.com/package/@sentry/webpack-plugin#options
org: "sourcebot",
project: "webapp",
// Only print logs for uploading source maps in CI
silent: !process.env.CI,
// For all available options, see:
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
// Upload a larger set of source maps for prettier stack traces (increases build time)
widenClientFileUpload: true,
// Automatically annotate React components to show their full name in breadcrumbs and session replay
reactComponentAnnotation: {
enabled: true,
},
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// side errors will fail.
tunnelRoute: "/monitoring",
// Automatically tree-shake Sentry logger statements to reduce bundle size
disableLogger: true,
// Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
// See the following for more information:
// https://docs.sentry.io/product/crons/
// https://vercel.com/docs/cron-jobs
automaticVercelMonitors: true,
});

View file

@ -66,6 +66,7 @@
"@replit/codemirror-lang-solidity": "^6.0.2",
"@replit/codemirror-lang-svelte": "^6.0.0",
"@replit/codemirror-vim": "^6.2.1",
"@sentry/nextjs": "^9",
"@shopify/lang-jsonc": "^1.0.0",
"@sourcebot/crypto": "^0.1.0",
"@sourcebot/db": "^0.1.0",

View file

@ -0,0 +1,13 @@
// This file configures the initialization of Sentry on the client.
// The config you add here will be used whenever a users loads a page in their browser.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_WEBAPP_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || 'unknown',
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View file

@ -0,0 +1,14 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_WEBAPP_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || 'unknown',
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View file

@ -0,0 +1,13 @@
// This file configures the initialization of Sentry on the server.
// The config you add here will be used whenever the server handles a request.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_WEBAPP_DSN,
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || 'unknown',
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: false,
});

View file

@ -0,0 +1,23 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import NextError from "next/error";
import { useEffect } from "react";
export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<html>
<body>
{/* `NextError` is the default Next.js error page component. Its type
definition requires a `statusCode` prop. However, since the App Router
does not expose status codes for errors, we simply pass 0 to render a
generic error message. */}
<NextError statusCode={0} />
</body>
</html>
);
}

View file

@ -0,0 +1,13 @@
import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('../sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('../sentry.edge.config');
}
}
export const onRequestError = Sentry.captureRequestError;

962
yarn.lock

File diff suppressed because it is too large Load diff