Skip to content
Merged
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
75 changes: 75 additions & 0 deletions app/lib/observability/sentry-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Shared Sentry SDK options for the self-hosted GlitchTip instance.
*
* GlitchTip speaks the Sentry protocol but does not implement session replay
* and only partially implements performance tracing, so those are pinned off
* here rather than left to SDK defaults.
*/

export type SentryRuntime = 'client' | 'server' | 'edge';

/**
* Every category below defaults to `true` in the SDK. This app carries Neon
* Auth session cookies and node auth tokens, so each one is turned off
* explicitly rather than trusted to a default.
*/
export interface SentryDataCollection {
userInfo: false;
cookies: false;
httpHeaders: false;
httpBodies: [];
urlQueryParams: false;
}

export interface SentryOptions {
enabled: boolean;
dsn: string | undefined;
environment: string;
release: string | undefined;
tracesSampleRate: number;
replaysSessionSampleRate: number;
replaysOnErrorSampleRate: number;
dataCollection: SentryDataCollection;
}

type Env = Record<string, string | undefined>;

function readValue(env: Env, key: string): string | undefined {
const value = env[key]?.trim();
return value ? value : undefined;
}

function readDsn(runtime: SentryRuntime, env: Env): string | undefined {
// The client bundle must only ever see the public DSN; reading SENTRY_DSN
// here would inline a server-only value into JavaScript we ship to browsers.
if (runtime === 'client') return readValue(env, 'NEXT_PUBLIC_SENTRY_DSN');
return readValue(env, 'SENTRY_DSN') ?? readValue(env, 'NEXT_PUBLIC_SENTRY_DSN');
}

export function buildSentryOptions(
runtime: SentryRuntime,
env: Env = process.env as Env,
): SentryOptions {
const dsn = readDsn(runtime, env);
const nodeEnv = readValue(env, 'NODE_ENV') ?? 'development';

return {
// Without a DSN there is nowhere to send events, and the test suite must
// never reach the network even if one happens to be exported.
enabled: Boolean(dsn) && nodeEnv !== 'test',
dsn,
environment: readValue(env, 'SENTRY_ENVIRONMENT') ?? nodeEnv,
// Coolify exposes the deployed commit as SOURCE_COMMIT.
release: readValue(env, 'SENTRY_RELEASE') ?? readValue(env, 'SOURCE_COMMIT'),
tracesSampleRate: 0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
dataCollection: {
userInfo: false,
cookies: false,
httpHeaders: false,
httpBodies: [],
urlQueryParams: false,
},
};
}
19 changes: 19 additions & 0 deletions instrumentation-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import * as Sentry from '@sentry/nextjs';
import posthog from 'posthog-js';

import { buildSentryOptions } from '@/app/lib/observability/sentry-options';

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, {
api_host: '/ingest',
ui_host: 'https://us.posthog.com',
defaults: '2026-01-30',
capture_exceptions: true,
debug: process.env.NODE_ENV === 'development',
});

const sentryOptions = buildSentryOptions('client');

Sentry.init({
enabled: sentryOptions.enabled,
dsn: sentryOptions.dsn,
environment: sentryOptions.environment,
release: sentryOptions.release,
tracesSampleRate: sentryOptions.tracesSampleRate,
// GlitchTip has no replay ingest, so never record or send one.
replaysSessionSampleRate: sentryOptions.replaysSessionSampleRate,
replaysOnErrorSampleRate: sentryOptions.replaysOnErrorSampleRate,
dataCollection: sentryOptions.dataCollection,
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
16 changes: 16 additions & 0 deletions instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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');
}
}

// Reports errors thrown inside route handlers, server components, and
// middleware. The WebSocket server in server.ts sits outside Next's request
// lifecycle, so errors there are only caught by the SDK's global handlers.
export const onRequestError = Sentry.captureRequestError;
9 changes: 8 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { withSentryConfig } from '@sentry/nextjs/config';
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
Expand All @@ -20,4 +21,10 @@ const nextConfig: NextConfig = {
skipTrailingSlashRedirect: true,
};

export default nextConfig;
export default withSentryConfig(nextConfig, {
// Source map upload needs a GlitchTip auth token and an upload step in CI;
// until that exists, keep builds quiet rather than warning on every run.
sourcemaps: { disable: true },
telemetry: false,
silent: true,
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"db:push": "drizzle-kit push"
},
"dependencies": {
"@sentry/nextjs": "^11.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
Expand Down
14 changes: 14 additions & 0 deletions sentry.edge.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as Sentry from '@sentry/nextjs';

import { buildSentryOptions } from '@/app/lib/observability/sentry-options';

const options = buildSentryOptions('edge');

Sentry.init({
enabled: options.enabled,
dsn: options.dsn,
environment: options.environment,
release: options.release,
tracesSampleRate: options.tracesSampleRate,
dataCollection: options.dataCollection,
});
14 changes: 14 additions & 0 deletions sentry.server.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as Sentry from '@sentry/nextjs';

import { buildSentryOptions } from '@/app/lib/observability/sentry-options';

const options = buildSentryOptions('server');

Sentry.init({
enabled: options.enabled,
dsn: options.dsn,
environment: options.environment,
release: options.release,
tracesSampleRate: options.tracesSampleRate,
dataCollection: options.dataCollection,
});
137 changes: 137 additions & 0 deletions tests/observability/sentry-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, test } from 'vitest';

import { buildSentryOptions } from '@/app/lib/observability/sentry-options';

describe('buildSentryOptions', () => {
test('is disabled when no DSN is configured', () => {
const options = buildSentryOptions('server', { NODE_ENV: 'production' });

expect(options.enabled).toBe(false);
expect(options.dsn).toBeUndefined();
});

test('stays disabled during tests even when a DSN is present', () => {
const options = buildSentryOptions('server', {
NODE_ENV: 'test',
SENTRY_DSN: 'https://key@errors.example.dev/1',
});

expect(options.enabled).toBe(false);
});

test('client only reads the public DSN', () => {
const options = buildSentryOptions('client', {
NODE_ENV: 'production',
SENTRY_DSN: 'https://server-only@errors.example.dev/1',
NEXT_PUBLIC_SENTRY_DSN: 'https://public@errors.example.dev/1',
});

expect(options.dsn).toBe('https://public@errors.example.dev/1');
});

test('client ignores a server-only DSN so it never reaches the bundle', () => {
const options = buildSentryOptions('client', {
NODE_ENV: 'production',
SENTRY_DSN: 'https://server-only@errors.example.dev/1',
});

expect(options.dsn).toBeUndefined();
expect(options.enabled).toBe(false);
});

test('server falls back to the public DSN when no private one is set', () => {
const options = buildSentryOptions('server', {
NODE_ENV: 'production',
NEXT_PUBLIC_SENTRY_DSN: 'https://public@errors.example.dev/1',
});

expect(options.dsn).toBe('https://public@errors.example.dev/1');
expect(options.enabled).toBe(true);
});

test('server prefers the private DSN over the public one', () => {
const options = buildSentryOptions('server', {
NODE_ENV: 'production',
SENTRY_DSN: 'https://private@errors.example.dev/1',
NEXT_PUBLIC_SENTRY_DSN: 'https://public@errors.example.dev/1',
});

expect(options.dsn).toBe('https://private@errors.example.dev/1');
});

test('treats a blank DSN as absent', () => {
const options = buildSentryOptions('server', {
NODE_ENV: 'production',
SENTRY_DSN: ' ',
});

expect(options.dsn).toBeUndefined();
expect(options.enabled).toBe(false);
});

test('environment defaults to NODE_ENV and can be overridden', () => {
const fromNodeEnv = buildSentryOptions('server', {
NODE_ENV: 'production',
SENTRY_DSN: 'https://key@errors.example.dev/1',
});
expect(fromNodeEnv.environment).toBe('production');

const overridden = buildSentryOptions('server', {
NODE_ENV: 'production',
SENTRY_ENVIRONMENT: 'preview',
SENTRY_DSN: 'https://key@errors.example.dev/1',
});
expect(overridden.environment).toBe('preview');
});

test('release comes from SOURCE_COMMIT, or SENTRY_RELEASE when set', () => {
const fromCommit = buildSentryOptions('server', {
NODE_ENV: 'production',
SOURCE_COMMIT: 'abc1234',
});
expect(fromCommit.release).toBe('abc1234');

const explicit = buildSentryOptions('server', {
NODE_ENV: 'production',
SOURCE_COMMIT: 'abc1234',
SENTRY_RELEASE: 'clawproxy@1.2.3',
});
expect(explicit.release).toBe('clawproxy@1.2.3');

const blank = buildSentryOptions('server', {
NODE_ENV: 'production',
SOURCE_COMMIT: '',
});
expect(blank.release).toBeUndefined();
});

test('uses GlitchTip-safe defaults', () => {
const options = buildSentryOptions('client', {
NODE_ENV: 'production',
NEXT_PUBLIC_SENTRY_DSN: 'https://public@errors.example.dev/1',
});

// GlitchTip has no session replay and only partial performance support.
expect(options.tracesSampleRate).toBe(0);
expect(options.replaysSessionSampleRate).toBe(0);
expect(options.replaysOnErrorSampleRate).toBe(0);
});

test('opts out of every data category the SDK collects by default', () => {
// SDK v11 collects cookies, headers and query params unless told otherwise.
// This app carries Neon Auth session cookies and node auth tokens, so all
// of it must stay out of error reports.
const options = buildSentryOptions('server', {
NODE_ENV: 'production',
SENTRY_DSN: 'https://key@errors.example.dev/1',
});

expect(options.dataCollection).toEqual({
userInfo: false,
cookies: false,
httpHeaders: false,
httpBodies: [],
urlQueryParams: false,
});
});
});
Loading
Loading