feat/sentry

This commit is contained in:
Enno Gelhaus 2025-07-27 02:31:52 +02:00
parent 220554d545
commit cb65e46ef5
41 changed files with 3448 additions and 110 deletions

View file

@ -79,6 +79,20 @@ NEXT_PUBLIC_POLOTNO=""
# NOT_SECURED=false
API_LIMIT=30 # The limit of the public API hour limit
# Sentry Settings (Error Monitoring & Performance)
SENTRY_DSN="" # Sentry DSN for error tracking
SENTRY_ENVIRONMENT="development" # Environment: development, staging, production
SENTRY_TRACES_SAMPLE_RATE="0.1" # Performance monitoring sample rate (0.0 to 1.0)
SENTRY_PROFILES_SAMPLE_RATE="0.1" # Profiling sample rate (0.0 to 1.0)
SENTRY_REPLAYS_SESSION_SAMPLE_RATE="0.1" # Session Replay sample rate for normal sessions
SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE="1.0" # Session Replay sample rate for error sessions
SENTRY_DEBUG="false" # Enable debug mode for Sentry
SENTRY_ENABLED="true" # Enable/disable Sentry completely
# Additional Sentry configuration
SENTRY_ORG="" # Sentry organization slug
SENTRY_PROJECT="" # Sentry project slug
SENTRY_AUTH_TOKEN="" # Auth token for Sentry CLI (for source maps upload)
# Payment settings
FEE_AMOUNT=0.05
STRIPE_PUBLISHABLE_KEY=""

View file

@ -9,13 +9,16 @@ WORKDIR /app
COPY . /app
COPY var/docker/supervisord.conf /etc/supervisord.conf
COPY var/docker/Caddyfile /app/Caddyfile
COPY var/docker/entrypoint.sh /app/entrypoint.sh
COPY var/docker/supervisord/caddy.conf /etc/supervisor.d/caddy.conf
RUN chmod +x /app/entrypoint.sh
COPY var/docker/supervisord/backend.conf /etc/supervisor.d/backend.conf
COPY var/docker/supervisord/frontend.conf /etc/supervisor.d/frontend.conf
COPY var/docker/supervisord/workers.conf /etc/supervisor.d/workers.conf
COPY var/docker/supervisord/cron.conf /etc/supervisor.d/cron.conf
COPY var/docker/supervisord/migrate.conf /etc/supervisor.d/migrate.conf
RUN pnpm install
RUN pnpm run build
EXPOSE 4200
EXPOSE 5000
CMD ["pnpm", "run", "pm2"]
CMD ["supervisord", "-c", "/etc/supervisord.conf"]

View file

@ -4,7 +4,7 @@
"description": "",
"scripts": {
"dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/backend/src/main",
"build": "NODE_ENV=production nest build",
"build": "cross-env NODE_ENV=production nest build",
"start": "dotenv -e ../../.env -- node ./dist/apps/backend/src/main.js",
"pm2": "pm2 start pnpm --name backend -- start"
},

View file

@ -106,7 +106,16 @@ export class AuthController {
register: true,
});
} catch (e: any) {
response.status(400).send(e.message);
// Provide specific error handling based on the error message
if (e.message === 'User already exists') {
response.status(409).send('User already exists'); // 409 Conflict for duplicate resource
} else if (e.message === 'Registration is disabled') {
response.status(403).send('Registration is disabled'); // 403 Forbidden
} else {
// Log the actual error for debugging
console.error('Registration error:', e);
response.status(400).send(e.message || 'Registration failed');
}
}
}

View file

@ -1,3 +1,7 @@
// Initialize Sentry as early as possible
import { SentryNestJSService } from '@gitroom/helpers/sentry';
SentryNestJSService.init('backend');
import { loadSwagger } from '@gitroom/helpers/swagger/load.swagger';
process.env.TZ = 'UTC';
@ -8,6 +12,8 @@ import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SubscriptionExceptionFilter } from '@gitroom/backend/services/auth/permissions/subscription.exception';
import { HttpExceptionFilter } from '@gitroom/nestjs-libraries/services/exception.filter';
import { SentryExceptionFilter } from '@gitroom/nestjs-libraries/services/sentry.exception.filter';
import { SentryInterceptor } from '@gitroom/nestjs-libraries/services/sentry.interceptor';
import { ConfigurationChecker } from '@gitroom/helpers/configuration/configuration.checker';
async function bootstrap() {
@ -35,6 +41,8 @@ async function bootstrap() {
);
app.use(cookieParser());
app.useGlobalInterceptors(new SentryInterceptor());
app.useGlobalFilters(new SentryExceptionFilter());
app.useGlobalFilters(new SubscriptionExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter());

View file

@ -9,6 +9,7 @@ import { ProvidersFactory } from '@gitroom/backend/services/auth/providers/provi
import dayjs from 'dayjs';
import { NewsletterService } from '@gitroom/nestjs-libraries/services/newsletter.service';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { SentryNotificationService } from '@gitroom/nestjs-libraries/services/sentry.notification.service';
import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto';
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
@ -18,7 +19,8 @@ export class AuthService {
private _userService: UsersService,
private _organizationService: OrganizationService,
private _notificationService: NotificationService,
private _emailService: EmailService
private _emailService: EmailService,
private _sentryNotificationService: SentryNotificationService
) {}
async canRegister(provider: string) {
if (!process.env.DISABLE_REGISTRATION || provider === Provider.GENERIC) {
@ -63,6 +65,16 @@ export class AuthService {
: false;
const obj = { addedOrg, jwt: await this.jwt(create.users[0].user) };
// Track user registration
this._sentryNotificationService.trackAuthEvent('registration', {
userId: create.users[0].user.id,
email: body.email,
provider: 'local',
ip,
userAgent,
});
await this._emailService.sendEmail(
body.email,
'Activate your account',
@ -72,13 +84,38 @@ export class AuthService {
}
if (!user || !AuthChecker.comparePassword(body.password, user.password)) {
// Track failed login attempt
this._sentryNotificationService.trackAuthEvent('failed_login', {
email: body.email,
provider: 'local',
ip,
userAgent,
});
throw new Error('Invalid user name or password');
}
if (!user.activated) {
// Track failed login attempt for unactivated user
this._sentryNotificationService.trackAuthEvent('failed_login', {
userId: user.id,
email: user.email,
provider: 'local',
error: new Error('User not activated'),
ip,
userAgent,
});
throw new Error('User is not activated');
}
// Track successful login
this._sentryNotificationService.trackAuthEvent('login', {
userId: user.id,
email: user.email,
provider: 'local',
ip,
userAgent,
});
return { addedOrg: false, jwt: await this.jwt(user) };
}

View file

@ -4,7 +4,7 @@
"description": "",
"scripts": {
"dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/cron/src/main",
"build": "NODE_ENV=production nest build",
"build": "cross-env NODE_ENV=production nest build",
"start": "dotenv -e ../../.env -- node ./dist/apps/cron/src/main.js",
"pm2": "pm2 start pnpm --name cron -- start"
},

View file

@ -1,3 +1,7 @@
// Initialize Sentry as early as possible
import { SentryNestJSService } from '@gitroom/helpers/sentry';
SentryNestJSService.init('cron');
import { NestFactory } from '@nestjs/core';
import { CronModule } from './cron.module';

View file

@ -0,0 +1,34 @@
// Initialize Sentry as early as possible for Next.js
import * as Sentry from '@sentry/nextjs';
export function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Server-side Sentry initialization
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV || 'development',
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === 'true',
release: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0',
// Performance Monitoring
tracesSampleRate: parseFloat(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE || '0.1'),
beforeSend(event, hint) {
// Only enable if explicitly enabled
if (process.env.NEXT_PUBLIC_SENTRY_ENABLED !== 'true') {
return null;
}
return event;
},
// Set user context
initialScope: {
tags: {
service: 'frontend-server',
version: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0',
},
},
});
}
}

View file

@ -3,7 +3,9 @@ export const dynamic = 'force-dynamic';
import '../global.scss';
import 'react-tooltip/dist/react-tooltip.css';
import '@copilotkit/react-ui/styles.css';
import { SentryClientService } from '../../lib/sentry'; // Initialize Sentry
import LayoutContext from '@gitroom/frontend/components/layout/layout.context';
import { SentryErrorBoundary } from '@gitroom/frontend/components/sentry/sentry-error-boundary';
import { ReactNode } from 'react';
import { Chakra_Petch } from 'next/font/google';
import PlausibleProvider from 'next-plausible';
@ -24,9 +26,22 @@ const chakra = Chakra_Petch({
});
export default async function AppLayout({ children }: { children: ReactNode }) {
const allHeaders = headers();
const Plausible = !!process.env.STRIPE_PUBLISHABLE_KEY
? PlausibleProvider
: Fragment;
const hasStripe = !!process.env.STRIPE_PUBLISHABLE_KEY;
const content = (
<PHProvider
phkey={process.env.NEXT_PUBLIC_POSTHOG_KEY}
host={process.env.NEXT_PUBLIC_POSTHOG_HOST}
>
<SentryErrorBoundary>
<LayoutContext>
<UtmSaver />
{children}
</LayoutContext>
</SentryErrorBoundary>
</PHProvider>
);
return (
<html className={interClass}>
<head>
@ -67,19 +82,15 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
>
<ToltScript />
<FacebookComponent />
<Plausible
domain={!!process.env.IS_GENERAL ? 'postiz.com' : 'gitroom.com'}
>
<PHProvider
phkey={process.env.NEXT_PUBLIC_POSTHOG_KEY}
host={process.env.NEXT_PUBLIC_POSTHOG_HOST}
{hasStripe ? (
<PlausibleProvider
domain={!!process.env.IS_GENERAL ? 'postiz.com' : 'gitroom.com'}
>
<LayoutContext>
<UtmSaver />
{children}
</LayoutContext>
</PHProvider>
</Plausible>
{content}
</PlausibleProvider>
) : (
content
)}
</VariableContextComponent>
</body>
</html>

View file

@ -74,10 +74,16 @@ export function Register() {
}
function getHelpfulReasonForRegistrationFailure(httpCode: number) {
switch (httpCode) {
case 400:
case 409:
return 'Email already exists';
case 403:
return 'Registration is disabled';
case 404:
return 'Your browser got a 404 when trying to contact the API, the most likely reasons for this are the NEXT_PUBLIC_BACKEND_URL is set incorrectly, or the backend is not running.';
case 400:
return 'Invalid registration data. Please check your input.';
case 500:
return 'Server error. Please try again later.';
}
return 'Unhandled error: ' + httpCode;
}

View file

@ -0,0 +1,92 @@
'use client';
import React, { Component, ReactNode } from 'react';
import { SentryClientService } from '../../lib/sentry';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class SentryErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('React Error Boundary caught an error:', error, errorInfo);
// Report to Sentry with additional context
SentryClientService.captureException(error, {
contexts: {
react: {
componentStack: errorInfo.componentStack,
},
},
extra: {
errorInfo,
},
tags: {
errorBoundary: true,
},
});
// Call optional onError callback
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
}
render() {
if (this.state.hasError) {
// Custom fallback UI
if (this.props.fallback) {
return this.props.fallback;
}
// Default fallback UI
return (
<div className="flex min-h-screen items-center justify-center bg-red-50">
<div className="rounded-lg bg-white p-8 shadow-lg">
<h1 className="mb-4 text-2xl font-bold text-red-600">
Something went wrong
</h1>
<p className="mb-4 text-gray-600">
We're sorry, but something unexpected happened. This error has been
reported to our team.
</p>
<button
onClick={() => window.location.reload()}
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
Reload Page
</button>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details className="mt-4">
<summary className="cursor-pointer text-sm text-gray-500">
Error Details (Development)
</summary>
<pre className="mt-2 whitespace-pre-wrap text-xs text-red-500">
{this.state.error.stack}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}

View file

@ -0,0 +1,243 @@
'use client';
import { useEffect } from 'react';
import { SentryClientService } from './sentry';
interface User {
id?: string;
email?: string;
username?: string;
organizationId?: string;
}
export function useSentryUserTracking(user?: User) {
useEffect(() => {
if (user?.id) {
SentryClientService.setUser({
id: user.id,
email: user.email,
username: user.username,
organizationId: user.organizationId,
});
}
}, [user]);
}
export function useSentryPageTracking(pageName: string, pageData?: any) {
useEffect(() => {
SentryClientService.addBreadcrumb(
`Navigated to ${pageName}`,
'navigation',
pageData
);
}, [pageName, pageData]);
}
export function trackUserAction(action: string, data?: any) {
SentryClientService.addBreadcrumb(
`User action: ${action}`,
'user.action',
data
);
}
export function trackUIError(error: Error, component: string, props?: any) {
SentryClientService.captureException(error, {
extra: {
component,
props,
},
tags: {
errorType: 'ui_error',
component,
},
level: 'error',
});
}
export function trackPerformanceIssue(operation: string, duration: number, threshold = 1000) {
if (duration > threshold) {
SentryClientService.captureMessage(
`Slow UI operation: ${operation}`,
'warning',
{
extra: {
operation,
duration,
threshold,
},
tags: {
errorType: 'performance_issue',
operation,
},
}
);
}
}
export function trackAPICall(method: string, url: string, status: number, duration: number, error?: any) {
const baseData = {
method,
url,
status,
duration,
};
// Only capture actual errors and warnings, not successful requests
if (error || status >= 400) {
SentryClientService.captureException(error || new Error(`API Error: ${status}`), {
extra: baseData,
tags: {
errorType: 'api_error',
method,
status: status.toString(),
},
level: status >= 500 ? 'error' : 'warning',
});
} else {
// Just add breadcrumb for successful requests - no event creation
SentryClientService.addBreadcrumb(
`API call: ${method} ${url}`,
'api.call',
baseData
);
}
// Track slow API calls as warnings (these are performance issues)
if (duration > 5000) {
SentryClientService.captureMessage(
`Slow API call: ${method} ${url}`,
'warning',
{
extra: baseData,
tags: {
errorType: 'slow_api_call',
method,
},
}
);
}
}
export function trackPostPublishing(
action: 'attempt' | 'success' | 'failed',
data: {
postId?: string;
provider?: string;
error?: any;
metadata?: any;
}
) {
const baseData = {
postId: data.postId,
provider: data.provider,
metadata: data.metadata,
};
switch (action) {
case 'attempt':
// Just breadcrumb - attempting to post is not an error
SentryClientService.addBreadcrumb(
`Post publishing attempt: ${data.provider}`,
'post.attempt',
baseData
);
break;
case 'success':
// Just breadcrumb - successful posts are not errors
SentryClientService.addBreadcrumb(
`Post published successfully: ${data.provider}`,
'post.success',
baseData
);
break;
case 'failed':
// This is an actual error - capture it
SentryClientService.captureException(data.error || new Error('Post publishing failed'), {
extra: baseData,
tags: {
errorType: 'post_publishing_failed',
provider: data.provider || 'unknown',
},
level: 'error',
});
break;
}
}
export function trackFormError(formName: string, field: string, error: string, formData?: any) {
SentryClientService.captureMessage(
`Form validation error in ${formName}: ${field}`,
'warning',
{
extra: {
formName,
field,
error,
formData,
},
tags: {
errorType: 'form_validation_error',
form: formName,
field,
},
}
);
}
export function trackIntegrationEvent(
action: 'connect_attempt' | 'connected' | 'disconnected' | 'error',
data: {
provider: string;
error?: any;
metadata?: any;
}
) {
const baseData = {
provider: data.provider,
metadata: data.metadata,
};
switch (action) {
case 'connect_attempt':
// Just breadcrumb - attempting to connect is not an error
SentryClientService.addBreadcrumb(
`Integration connection attempt: ${data.provider}`,
'integration.attempt',
baseData
);
break;
case 'connected':
// Just breadcrumb - successful connections are not errors
SentryClientService.addBreadcrumb(
`Integration connected: ${data.provider}`,
'integration.connected',
baseData
);
break;
case 'disconnected':
// Just breadcrumb - disconnections might be intentional
SentryClientService.addBreadcrumb(
`Integration disconnected: ${data.provider}`,
'integration.disconnected',
baseData
);
break;
case 'error':
// This is an actual error - capture it
SentryClientService.captureException(data.error || new Error('Integration error'), {
extra: baseData,
tags: {
errorType: 'integration_error',
provider: data.provider,
},
level: 'error',
});
break;
}
}

View file

@ -0,0 +1,48 @@
import { SentryReactService } from '@gitroom/helpers/sentry/sentry.react';
import { enableFetchTracking } from './tracked-fetch';
// Initialize Sentry for client-side
if (typeof window !== 'undefined') {
SentryReactService.init();
// Enable automatic fetch tracking
// enableFetchTracking(); // Temporarily disabled
}
// Create service wrapper for compatibility
export const SentryClientService = {
captureException: (error: any, context?: any) => {
if (typeof window === 'undefined') return;
return SentryReactService.captureException(error, context);
},
captureMessage: (message: string, level: 'info' | 'warning' | 'error' = 'info', context?: any) => {
if (typeof window === 'undefined') return;
return SentryReactService.captureMessage(message, level, context);
},
setUser: (user: { id?: string; email?: string; username?: string; organizationId?: string }) => {
if (typeof window === 'undefined') return;
SentryReactService.setUser(user);
},
addBreadcrumb: (message: string, category?: string, data?: any) => {
if (typeof window === 'undefined') return;
SentryReactService.addBreadcrumb(message, category, data);
},
setTag: (key: string, value: string) => {
if (typeof window === 'undefined') return;
SentryReactService.setTag(key, value);
},
setContext: (key: string, context: any) => {
if (typeof window === 'undefined') return;
SentryReactService.setContext(key, context);
},
showReportDialog: (eventId?: string) => {
if (typeof window === 'undefined') return;
SentryReactService.showReportDialog(eventId);
},
};

View file

@ -0,0 +1,50 @@
'use client';
import { trackAPICall } from './sentry-tracking';
// Wrapper around fetch to automatically track API calls in Sentry
export async function trackedFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const startTime = Date.now();
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
const method = init?.method || 'GET';
try {
const response = await fetch(input, init);
const duration = Date.now() - startTime;
// Track the API call
trackAPICall(method, url, response.status, duration);
return response;
} catch (error) {
const duration = Date.now() - startTime;
// Track the failed API call
trackAPICall(method, url, 0, duration, error);
throw error;
}
}
// Helper function to replace the global fetch with our tracked version
export function enableFetchTracking() {
if (typeof window !== 'undefined') {
const originalFetch = window.fetch;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
// Only track API calls to our backend and not third-party services
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
// Track only calls to our backend API
if (url.includes('/api/') || url.includes(process.env.NEXT_PUBLIC_BACKEND_URL || '')) {
return trackedFetch(input, init);
}
// Use original fetch for other requests
return originalFetch(input, init);
};
}
}

View file

@ -4,7 +4,7 @@
"description": "",
"scripts": {
"dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/workers/src/main",
"build": "NODE_ENV=production nest build",
"build": "cross-env NODE_ENV=production nest build",
"start": "dotenv -e ../../.env -- node ./dist/apps/workers/src/main.js",
"pm2": "pm2 start pnpm --name workers -- start"
},

View file

@ -1,3 +1,7 @@
// Initialize Sentry as early as possible
import { SentryNestJSService } from '@gitroom/helpers/sentry';
SentryNestJSService.init('workers');
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';

View file

@ -13,7 +13,7 @@ services:
POSTGRES_USER: postiz-local
POSTGRES_DB: postiz-db-local
volumes:
- postgres-volume:/var/lib/postgresql/data
- pos-postgres-volume:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
@ -55,7 +55,7 @@ services:
volumes:
redisinsight:
postgres-volume:
pos-postgres-volume:
external: false
networks:

68
docker-compose.yml Normal file
View file

@ -0,0 +1,68 @@
services:
postiz:
image: docker.io/library/postiz-dev:1
container_name: postiz
restart: always
env_file:
- .env
volumes:
- postiz-config:/config/
- postiz-uploads:/uploads/
ports:
- 5000:5000 # Caddy proxy
networks:
- postiz-network
depends_on:
postiz-postgres:
condition: service_healthy
postiz-redis:
condition: service_healthy
postiz-postgres:
image: postgres:17-alpine
container_name: postiz-postgres
restart: always
environment:
POSTGRES_PASSWORD: postiz-password
POSTGRES_USER: postiz-user
POSTGRES_DB: postiz-db-local
volumes:
- postgres-volume:/var/lib/postgresql/data
networks:
- postiz-network
healthcheck:
test: pg_isready -U postiz-user -d postiz-db-local
interval: 10s
timeout: 3s
retries: 3
postiz-redis:
image: redis:7.2
container_name: postiz-redis
restart: always
healthcheck:
test: redis-cli ping
interval: 10s
timeout: 3s
retries: 3
volumes:
- postiz-redis-data:/data
networks:
- postiz-network
volumes:
postgres-volume:
external: false
postiz-redis-data:
external: false
postiz-config:
external: false
postiz-uploads:
external: false
networks:
postiz-network:
external: false

View file

@ -0,0 +1,6 @@
// Export only browser-compatible Sentry
import { SentryNextService } from './sentry.nextjs';
// Export with aliases for compatibility
export { SentryNextService };
export const SentryClientService = SentryNextService;

View file

@ -0,0 +1,9 @@
export { SentryConfigService } from './sentry.config';
export { SentryNestJSService } from './sentry.nestjs';
export { SentryReactService } from './sentry.react';
export { SentryClientService } from './sentry.client';
// Re-export commonly used Sentry functions for convenience
export * as Sentry from '@sentry/nestjs';
export * as SentryReact from '@sentry/react';
export * as SentryBrowser from '@sentry/browser';

View file

@ -0,0 +1,171 @@
import * as Sentry from '@sentry/react';
export class SentryClientService {
static init() {
// Only run on client side
if (typeof window === 'undefined') return;
const config = {
enabled: process.env.NEXT_PUBLIC_SENTRY_ENABLED === 'true',
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV || 'development',
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === 'true',
release: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0',
tracesSampleRate: parseFloat(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE || '0.1'),
replaysSessionSampleRate: parseFloat(process.env.NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE || '0.1'),
replaysOnErrorSampleRate: parseFloat(process.env.NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE || '1.0'),
};
if (!config.enabled || !config.dsn) {
console.log('[Frontend] Sentry is disabled');
return;
}
console.log(`[Frontend] Initializing Sentry with environment: ${config.environment}`);
Sentry.init({
dsn: config.dsn,
environment: config.environment,
debug: config.debug,
release: config.release,
// Performance Monitoring
tracesSampleRate: config.tracesSampleRate,
// Session Replay
replaysSessionSampleRate: config.replaysSessionSampleRate,
replaysOnErrorSampleRate: config.replaysOnErrorSampleRate,
integrations: [
// Browser tracing for performance monitoring
Sentry.browserTracingIntegration(),
// Session replay integration
Sentry.replayIntegration({
// Mask all text content, inputs, etc.
maskAllText: false,
blockAllMedia: false,
}),
// Breadcrumbs for user interactions
Sentry.breadcrumbsIntegration({
console: false, // Don't capture console logs as breadcrumbs
dom: true, // Capture DOM interactions
fetch: true, // Capture fetch requests
history: true, // Capture navigation
sentry: true, // Capture Sentry events
xhr: true, // Capture XHR requests
}),
],
beforeSend(event, hint) {
// Filter out known non-critical errors
if (event.exception) {
const error = hint.originalException;
// Skip network errors that are likely user-related
if (error && error instanceof TypeError) {
const message = error.message || '';
if (message.includes('NetworkError') ||
message.includes('Failed to fetch') ||
message.includes('Load failed')) {
return null;
}
}
// Skip ResizeObserver errors (common browser quirk)
if (error && typeof error === 'object' && 'message' in error &&
typeof error.message === 'string' && error.message.includes('ResizeObserver')) {
return null;
}
// Skip AbortError (user navigation)
if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') {
return null;
}
}
return event;
},
// Set user context
initialScope: {
tags: {
service: 'frontend',
version: config.release,
},
},
});
// Set up global error handlers
window.addEventListener('error', (event) => {
console.error('[Frontend] Global Error:', event.error);
});
window.addEventListener('unhandledrejection', (event) => {
console.error('[Frontend] Unhandled Promise Rejection:', event.reason);
});
}
static captureException(error: any, context?: any) {
if (typeof window === 'undefined') return;
return Sentry.withScope((scope) => {
if (context) {
scope.setContext('additional_context', context);
}
return Sentry.captureException(error);
});
}
static captureMessage(message: string, level: Sentry.SeverityLevel = 'info', context?: any) {
if (typeof window === 'undefined') return;
return Sentry.withScope((scope) => {
if (context) {
scope.setContext('additional_context', context);
}
return Sentry.captureMessage(message, level);
});
}
static setUser(user: { id?: string; email?: string; username?: string; organizationId?: string }) {
if (typeof window === 'undefined') return;
Sentry.setUser(user);
}
static addBreadcrumb(message: string, category?: string, data?: any) {
if (typeof window === 'undefined') return;
Sentry.addBreadcrumb({
message,
category: category || 'custom',
data,
timestamp: Date.now() / 1000,
});
}
static setTag(key: string, value: string) {
if (typeof window === 'undefined') return;
Sentry.setTag(key, value);
}
static setContext(key: string, context: any) {
if (typeof window === 'undefined') return;
Sentry.setContext(key, context);
}
static showReportDialog(eventId?: string) {
if (typeof window === 'undefined') return;
Sentry.showReportDialog({
eventId,
title: 'Report a Bug',
subtitle: 'Help us improve Postiz by reporting this error.',
subtitle2: 'We\'ll get back to you if we need more information.',
});
}
}

View file

@ -0,0 +1,60 @@
export interface SentryConfig {
dsn: string;
environment: string;
tracesSampleRate: number;
profilesSampleRate: number;
replaysSessionSampleRate: number;
replaysOnErrorSampleRate: number;
debug: boolean;
enabled: boolean;
release?: string;
serverName?: string;
beforeSend?: (event: any, hint: any) => any;
integrations?: any[];
}
export class SentryConfigService {
static getConfig(): SentryConfig {
const enabled = process.env.SENTRY_ENABLED !== 'false';
const dsn = process.env.SENTRY_DSN || '';
// If Sentry is disabled or no DSN, return disabled config
if (!enabled || !dsn) {
return {
dsn: '',
environment: 'development',
tracesSampleRate: 0,
profilesSampleRate: 0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
debug: false,
enabled: false,
};
}
return {
dsn,
environment: process.env.SENTRY_ENVIRONMENT || 'development',
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE || '0.1'),
profilesSampleRate: parseFloat(process.env.SENTRY_PROFILES_SAMPLE_RATE || '0.1'),
replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE || '0.1'),
replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE || '1.0'),
debug: process.env.SENTRY_DEBUG === 'true',
enabled: true,
release: process.env.npm_package_version || 'unknown',
serverName: process.env.HOSTNAME || 'unknown',
};
}
static isEnabled(): boolean {
return this.getConfig().enabled;
}
static getEnvironment(): string {
return this.getConfig().environment;
}
static getDsn(): string {
return this.getConfig().dsn;
}
}

View file

@ -0,0 +1,159 @@
import * as Sentry from '@sentry/nestjs';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
import { SentryConfigService } from './sentry.config';
export class SentryNestJSService {
static init(serviceName: string, additionalIntegrations: any[] = []) {
const config = SentryConfigService.getConfig();
if (!config.enabled) {
console.log(`[${serviceName}] Sentry is disabled`);
return;
}
console.log(`[${serviceName}] Initializing Sentry with environment: ${config.environment}`);
Sentry.init({
dsn: config.dsn,
environment: config.environment,
debug: config.debug,
release: config.release,
serverName: `${serviceName}-${config.serverName}`,
// Performance Monitoring
tracesSampleRate: config.tracesSampleRate,
profilesSampleRate: config.profilesSampleRate,
integrations: [
// Node.js profiling
nodeProfilingIntegration(),
// Http integration for tracing HTTP requests
Sentry.httpIntegration({
ignoreIncomingRequests: (url) => {
// Ignore health checks and monitoring endpoints
return url.includes('/health') ||
url.includes('/monitor') ||
url.includes('/favicon.ico') ||
url.includes('/_next/');
},
}),
// Express integration for Express.js apps
Sentry.expressIntegration(),
// Additional integrations passed in
...additionalIntegrations,
],
beforeSend(event, hint) {
// Filter out known non-critical errors
if (event.exception) {
const error = hint.originalException;
// Skip common connection errors
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as any).code;
if (['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND'].includes(code)) {
return null;
}
}
// Skip Redis connection errors in development
if (config.environment === 'development' &&
event.exception?.values?.[0]?.value?.includes('Redis')) {
return null;
}
}
return event;
},
// Set user context
initialScope: {
tags: {
service: serviceName,
version: config.release,
},
},
});
// Set up global error handlers
process.on('uncaughtException', (error) => {
console.error(`[${serviceName}] Uncaught Exception:`, error);
Sentry.captureException(error);
});
process.on('unhandledRejection', (reason, promise) => {
console.error(`[${serviceName}] Unhandled Rejection at:`, promise, 'reason:', reason);
Sentry.captureException(reason);
});
}
static captureException(error: any, context?: any) {
if (!SentryConfigService.isEnabled()) return;
return Sentry.withScope((scope) => {
if (context) {
scope.setContext('additional_context', context);
}
return Sentry.captureException(error);
});
}
static captureMessage(message: string, level: Sentry.SeverityLevel = 'info', context?: any) {
if (!SentryConfigService.isEnabled()) return;
return Sentry.withScope((scope) => {
if (context) {
scope.setContext('additional_context', context);
}
return Sentry.captureMessage(message, level);
});
}
static setUser(user: { id?: string; email?: string; username?: string; organizationId?: string }) {
if (!SentryConfigService.isEnabled()) return;
Sentry.setUser(user);
}
static addBreadcrumb(message: string, category?: string, data?: any) {
if (!SentryConfigService.isEnabled()) return;
Sentry.addBreadcrumb({
message,
category: category || 'custom',
data,
timestamp: Date.now() / 1000,
});
}
static setTag(key: string, value: string) {
if (!SentryConfigService.isEnabled()) return;
Sentry.setTag(key, value);
}
static setContext(key: string, context: any) {
if (!SentryConfigService.isEnabled()) return;
Sentry.setContext(key, context);
}
static startTransaction(name: string, op?: string) {
if (!SentryConfigService.isEnabled()) return null;
// Use startSpan instead of deprecated startTransaction
return Sentry.startSpan({
name,
op: op || 'custom',
}, (span) => span);
}
static close(timeout?: number) {
if (!SentryConfigService.isEnabled()) return Promise.resolve(true);
return Sentry.close(timeout);
}
}

View file

@ -0,0 +1,190 @@
// Next.js-specific Sentry configuration
import * as Sentry from '@sentry/nextjs';
interface SentryConfig {
enabled: boolean;
dsn: string;
environment: string;
debug: boolean;
release: string;
tracesSampleRate: number;
replaysSessionSampleRate: number;
replaysOnErrorSampleRate: number;
}
function getConfig(): SentryConfig {
return {
enabled: process.env.NEXT_PUBLIC_SENTRY_ENABLED === 'true',
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN || '',
environment: process.env.NODE_ENV || 'development',
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === 'true',
release: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0',
tracesSampleRate: parseFloat(process.env.NEXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE || '0.1'),
replaysSessionSampleRate: parseFloat(process.env.NEXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE || '0.1'),
replaysOnErrorSampleRate: parseFloat(process.env.NEXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE || '1.0'),
};
}
export class SentryNextService {
private static initialized = false;
static init() {
// Only run on client side and only once
if (typeof window === 'undefined' || this.initialized) return;
const config = getConfig();
if (!config.enabled || !config.dsn) {
console.log('[Frontend] Sentry is disabled');
return;
}
console.log(`[Frontend] Initializing Sentry with environment: ${config.environment}`);
Sentry.init({
dsn: config.dsn,
environment: config.environment,
debug: config.debug,
release: config.release,
// Performance Monitoring
tracesSampleRate: config.tracesSampleRate,
// Session Replay
replaysSessionSampleRate: config.replaysSessionSampleRate,
replaysOnErrorSampleRate: config.replaysOnErrorSampleRate,
integrations: [
// Browser tracing for performance monitoring
Sentry.browserTracingIntegration(),
// Session replay integration
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
// Breadcrumbs for user interactions
Sentry.breadcrumbsIntegration({
console: false,
dom: true,
fetch: true,
history: true,
sentry: true,
xhr: true,
}),
],
beforeSend(event, hint) {
// Filter out known non-critical errors
if (event.exception) {
const error = hint.originalException;
// Skip network errors that are likely user-related
if (error && error instanceof TypeError) {
const message = error.message || '';
if (message.includes('NetworkError') ||
message.includes('Failed to fetch') ||
message.includes('Load failed')) {
return null;
}
}
// Skip ResizeObserver errors (common browser quirk)
if (error && typeof error === 'object' && 'message' in error &&
typeof error.message === 'string' && error.message.includes('ResizeObserver')) {
return null;
}
// Skip AbortError (user navigation)
if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') {
return null;
}
}
return event;
},
// Set user context
initialScope: {
tags: {
service: 'frontend',
version: config.release,
},
},
});
this.initialized = true;
// Set up global error handlers
window.addEventListener('error', (event) => {
console.error('[Frontend] Global Error:', event.error);
});
window.addEventListener('unhandledrejection', (event) => {
console.error('[Frontend] Unhandled Promise Rejection:', event.reason);
});
}
static captureException(error: any, context?: any) {
if (typeof window === 'undefined') return;
return Sentry.withScope((scope) => {
if (context) {
scope.setContext('additional_context', context);
}
return Sentry.captureException(error);
});
}
static captureMessage(message: string, level: Sentry.SeverityLevel = 'info', context?: any) {
if (typeof window === 'undefined') return;
return Sentry.withScope((scope) => {
if (context) {
scope.setContext('additional_context', context);
}
return Sentry.captureMessage(message, level);
});
}
static setUser(user: { id?: string; email?: string; username?: string; organizationId?: string }) {
if (typeof window === 'undefined') return;
Sentry.setUser(user);
}
static addBreadcrumb(message: string, category?: string, data?: any) {
if (typeof window === 'undefined') return;
Sentry.addBreadcrumb({
message,
category: category || 'custom',
data,
timestamp: Date.now() / 1000,
});
}
static setTag(key: string, value: string) {
if (typeof window === 'undefined') return;
Sentry.setTag(key, value);
}
static setContext(key: string, context: any) {
if (typeof window === 'undefined') return;
Sentry.setContext(key, context);
}
static showReportDialog(eventId?: string) {
if (typeof window === 'undefined') return;
Sentry.showReportDialog({
eventId,
title: 'Report a Bug',
subtitle: 'Help us improve Postiz by reporting this error.',
subtitle2: 'We\'ll get back to you if we need more information.',
});
}
}

View file

@ -0,0 +1,170 @@
import * as Sentry from '@sentry/react';
import { SentryConfigService } from './sentry.config';
export class SentryReactService {
static init() {
const config = SentryConfigService.getConfig();
if (!config.enabled) {
console.log('[Frontend] Sentry is disabled');
return;
}
console.log(`[Frontend] Initializing Sentry with environment: ${config.environment}`);
Sentry.init({
dsn: config.dsn,
environment: config.environment,
debug: config.debug,
release: config.release,
// Performance Monitoring
tracesSampleRate: config.tracesSampleRate,
// Session Replay
replaysSessionSampleRate: config.replaysSessionSampleRate,
replaysOnErrorSampleRate: config.replaysOnErrorSampleRate,
integrations: [
// Browser tracing for performance monitoring
Sentry.browserTracingIntegration(),
// Session replay integration
Sentry.replayIntegration({
// Mask all text content, inputs, etc.
maskAllText: false,
blockAllMedia: false,
}),
// Breadcrumbs for user interactions
Sentry.breadcrumbsIntegration({
console: false, // Don't capture console logs as breadcrumbs
dom: true, // Capture DOM interactions
fetch: true, // Capture fetch requests
history: true, // Capture navigation
sentry: true, // Capture Sentry events
xhr: true, // Capture XHR requests
}),
],
beforeSend(event, hint) {
// Filter out known non-critical errors
if (event.exception) {
const error = hint.originalException;
// Skip network errors that are likely user-related
if (error && error instanceof TypeError) {
const message = error.message || '';
if (message.includes('NetworkError') ||
message.includes('Failed to fetch') ||
message.includes('Load failed')) {
return null;
}
}
// Skip ResizeObserver errors (common browser quirk)
if (error && typeof error === 'object' && 'message' in error &&
typeof error.message === 'string' && error.message.includes('ResizeObserver')) {
return null;
}
// Skip AbortError (user navigation)
if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') {
return null;
}
}
return event;
},
// Set user context
initialScope: {
tags: {
service: 'frontend',
version: config.release,
},
},
});
// Set up global error handlers
window.addEventListener('error', (event) => {
console.error('[Frontend] Global Error:', event.error);
});
window.addEventListener('unhandledrejection', (event) => {
console.error('[Frontend] Unhandled Promise Rejection:', event.reason);
});
}
static captureException(error: any, context?: any) {
if (!SentryConfigService.isEnabled()) return;
return Sentry.withScope((scope) => {
if (context) {
scope.setContext('additional_context', context);
}
return Sentry.captureException(error);
});
}
static captureMessage(message: string, level: Sentry.SeverityLevel = 'info', context?: any) {
if (!SentryConfigService.isEnabled()) return;
return Sentry.withScope((scope) => {
if (context) {
scope.setContext('additional_context', context);
}
return Sentry.captureMessage(message, level);
});
}
static setUser(user: { id?: string; email?: string; username?: string; organizationId?: string }) {
if (!SentryConfigService.isEnabled()) return;
Sentry.setUser(user);
}
static addBreadcrumb(message: string, category?: string, data?: any) {
if (!SentryConfigService.isEnabled()) return;
Sentry.addBreadcrumb({
message,
category: category || 'custom',
data,
timestamp: Date.now() / 1000,
});
}
static setTag(key: string, value: string) {
if (!SentryConfigService.isEnabled()) return;
Sentry.setTag(key, value);
}
static setContext(key: string, context: any) {
if (!SentryConfigService.isEnabled()) return;
Sentry.setContext(key, context);
}
static startTransaction(name: string, op?: string) {
if (!SentryConfigService.isEnabled()) return null;
// Use startSpan instead of deprecated startTransaction
return Sentry.startSpan({
name,
op: op || 'custom',
}, (span) => span);
}
static showReportDialog(eventId?: string) {
if (!SentryConfigService.isEnabled()) return;
Sentry.showReportDialog({
eventId,
title: 'Report a Bug',
subtitle: 'Help us improve Postiz by reporting this error.',
subtitle2: 'We\'ll get back to you if we need more information.',
});
}
}

View file

@ -39,6 +39,8 @@ import { SetsService } from '@gitroom/nestjs-libraries/database/prisma/sets/sets
import { SetsRepository } from '@gitroom/nestjs-libraries/database/prisma/sets/sets.repository';
import { ThirdPartyRepository } from '@gitroom/nestjs-libraries/database/prisma/third-party/third-party.repository';
import { ThirdPartyService } from '@gitroom/nestjs-libraries/database/prisma/third-party/third-party.service';
import { SentryNotificationService } from '@gitroom/nestjs-libraries/services/sentry.notification.service';
import { SentryWorkerService } from '@gitroom/nestjs-libraries/services/sentry.worker.service';
import { VideoManager } from '@gitroom/nestjs-libraries/videos/video.manager';
import { FalService } from '@gitroom/nestjs-libraries/openai/fal.service';
@ -90,6 +92,8 @@ import { FalService } from '@gitroom/nestjs-libraries/openai/fal.service';
SetsRepository,
ThirdPartyRepository,
ThirdPartyService,
SentryNotificationService,
SentryWorkerService,
VideoManager,
],
get exports() {

View file

@ -34,6 +34,7 @@ import sharp from 'sharp';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { Readable } from 'stream';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
import { SentryNotificationService } from '@gitroom/nestjs-libraries/services/sentry.notification.service';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
@ -58,7 +59,8 @@ export class PostsService {
private _mediaService: MediaService,
private _shortLinkService: ShortLinkService,
private _webhookService: WebhooksService,
private openaiService: OpenaiService
private openaiService: OpenaiService,
private _sentryNotificationService: SentryNotificationService
) {}
async getStatistics(orgId: string, id: string) {
@ -289,7 +291,24 @@ export class PostsService {
return;
}
// Track post publishing attempt
this._sentryNotificationService.trackPostEvent('attempt', {
postId: firstPost.id,
organizationId: firstPost.organizationId,
provider: firstPost.integration?.providerIdentifier || 'unknown',
metadata: {
postCount: allPosts.length,
scheduledDate: firstPost.publishDate,
},
});
if (firstPost.integration?.refreshNeeded) {
this._sentryNotificationService.trackIntegrationEvent('refresh_needed', {
integrationId: firstPost.integration.id,
organizationId: firstPost.organizationId,
provider: firstPost.integration.providerIdentifier,
});
await this._notificationService.inAppNotification(
firstPost.organizationId,
`We couldn't post to ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`,
@ -300,6 +319,12 @@ export class PostsService {
}
if (firstPost.integration?.disabled) {
this._sentryNotificationService.trackIntegrationEvent('disconnected', {
integrationId: firstPost.integration.id,
organizationId: firstPost.organizationId,
provider: firstPost.integration.providerIdentifier,
});
await this._notificationService.inAppNotification(
firstPost.organizationId,
`We couldn't post to ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`,
@ -329,6 +354,19 @@ export class PostsService {
if (!finalPost?.postId || !finalPost?.releaseURL) {
await this._postRepository.changeState(firstPost.id, 'ERROR');
// Track post failure
this._sentryNotificationService.trackPostEvent('failed', {
postId: firstPost.id,
organizationId: firstPost.organizationId,
provider: firstPost.integration?.providerIdentifier || 'unknown',
error: new Error('Post publishing failed - no postId or releaseURL returned'),
metadata: {
postCount: allPosts.length,
finalPost,
},
});
await this._notificationService.inAppNotification(
firstPost.organizationId,
`Error posting on ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`,
@ -338,6 +376,18 @@ export class PostsService {
return;
}
// Track successful post
this._sentryNotificationService.trackPostEvent('success', {
postId: firstPost.id,
organizationId: firstPost.organizationId,
provider: firstPost.integration?.providerIdentifier || 'unknown',
metadata: {
postCount: allPosts.length,
postUrl: finalPost.releaseURL,
externalPostId: finalPost.postId,
},
});
} catch (err: any) {
await this._postRepository.changeState(
firstPost.id,
@ -345,6 +395,20 @@ export class PostsService {
err,
allPosts
);
// Track post failure with error details
this._sentryNotificationService.trackPostEvent('failed', {
postId: firstPost.id,
organizationId: firstPost.organizationId,
provider: firstPost.integration?.providerIdentifier || 'unknown',
error: err,
metadata: {
postCount: allPosts.length,
isBadBody: err instanceof BadBody,
isRefreshToken: err instanceof RefreshToken,
},
});
if (err instanceof BadBody) {
await this._notificationService.inAppNotification(
firstPost.organizationId,

View file

@ -0,0 +1,119 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { SentryNestJSService } from '@gitroom/helpers/sentry';
@Catch()
export class SentryExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(SentryExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = 500;
let message = 'Internal server error';
// Determine if it's an HTTP exception
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message = typeof exceptionResponse === 'string' ? exceptionResponse :
(exceptionResponse as any)?.message || message;
}
// Log the error
this.logger.error(`${request.method} ${request.url}`, exception);
// Send to Sentry (only for server errors or critical issues)
if (status >= 500 || (status >= 400 && this.shouldReportError(exception, request))) {
SentryNestJSService.captureException(exception, {
extra: {
url: request.url,
method: request.method,
headers: this.sanitizeHeaders(request.headers),
body: this.sanitizeBody(request.body),
query: request.query,
params: request.params,
userAgent: request.get('User-Agent'),
ip: request.ip,
status,
},
tags: {
endpoint: `${request.method} ${request.route?.path || request.url}`,
statusCode: status.toString(),
},
});
}
// Send error response to client
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message,
...(process.env.NODE_ENV === 'development' && {
stack: exception instanceof Error ? exception.stack : undefined
}),
});
}
private shouldReportError(exception: unknown, request: Request): boolean {
// Don't report validation errors (400) unless they seem suspicious
if (exception instanceof HttpException) {
const status = exception.getStatus();
// Report authentication/authorization errors
if (status === 401 || status === 403) {
return true;
}
// Report not found errors only if they seem like potential attacks
if (status === 404) {
const suspiciousPatterns = ['.php', '.asp', '.jsp', 'wp-admin', 'admin.php', 'config.'];
return suspiciousPatterns.some(pattern => request.url.includes(pattern));
}
// Report rate limiting
if (status === 429) {
return true;
}
}
return false;
}
private sanitizeHeaders(headers: any): any {
const sanitized = { ...headers };
// Remove sensitive headers
delete sanitized.authorization;
delete sanitized.cookie;
delete sanitized['x-api-key'];
delete sanitized['x-auth-token'];
return sanitized;
}
private sanitizeBody(body: any): any {
if (!body || typeof body !== 'object') {
return body;
}
const sanitized = { ...body };
// Remove sensitive fields
const sensitiveFields = [
'password', 'token', 'secret', 'key', 'auth', 'credential',
'accessToken', 'refreshToken', 'apiKey', 'privateKey'
];
sensitiveFields.forEach(field => {
if (sanitized[field]) {
sanitized[field] = '[REDACTED]';
}
});
return sanitized;
}
}

View file

@ -0,0 +1,118 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
import { SentryNestJSService } from '@gitroom/helpers/sentry';
import { Request } from 'express';
@Injectable()
export class SentryInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest<Request>();
const startTime = Date.now();
// Set request context
const endpoint = `${request.method} ${request.route?.path || request.url}`;
SentryNestJSService.setTag('endpoint', endpoint);
SentryNestJSService.setContext('request', {
url: request.url,
method: request.method,
headers: this.sanitizeHeaders(request.headers),
query: request.query,
params: request.params,
userAgent: request.get('User-Agent'),
ip: request.ip,
});
// Set user context if available
if ((request as any).user) {
SentryNestJSService.setUser({
id: (request as any).user.id,
email: (request as any).user.email,
username: (request as any).user.username,
organizationId: (request as any).user.organizationId,
});
}
// Start performance transaction
const transaction = SentryNestJSService.startTransaction(endpoint, 'http.server');
return next.handle().pipe(
tap((response) => {
const duration = Date.now() - startTime;
// Add breadcrumb for successful request (just for context, not an event)
SentryNestJSService.addBreadcrumb(
`${endpoint} completed successfully`,
'http.request',
{
duration,
statusCode: context.switchToHttp().getResponse().statusCode,
}
);
// Track slow requests as warnings (performance issues)
if (duration > 5000) { // 5 seconds threshold
SentryNestJSService.captureMessage(
`Slow request: ${endpoint}`,
'warning',
{
extra: {
duration,
url: request.url,
method: request.method,
},
tags: {
event: 'slow_request',
endpoint,
},
}
);
}
// Finish transaction
if (transaction) {
transaction.setStatus({ code: 1 }); // OK status
transaction.end();
}
}),
catchError((error) => {
const duration = Date.now() - startTime;
// Add breadcrumb for failed request
SentryNestJSService.addBreadcrumb(
`${endpoint} failed`,
'http.request',
{
duration,
error: error.message,
}
);
// Finish transaction with error
if (transaction) {
transaction.setStatus({ code: 2 }); // ERROR status
transaction.end();
}
throw error;
})
);
}
private sanitizeHeaders(headers: any): any {
const sanitized = { ...headers };
// Remove sensitive headers
delete sanitized.authorization;
delete sanitized.cookie;
delete sanitized['x-api-key'];
delete sanitized['x-auth-token'];
return sanitized;
}
}

View file

@ -0,0 +1,337 @@
import { Injectable } from '@nestjs/common';
import { SentryNestJSService } from '@gitroom/helpers/sentry';
@Injectable()
export class SentryNotificationService {
/**
* Track post publishing attempts and failures
*/
trackPostEvent(event: 'attempt' | 'success' | 'failed', data: {
postId: string;
organizationId: string;
userId?: string;
provider: string;
error?: any;
metadata?: any;
}) {
const baseContext = {
postId: data.postId,
organizationId: data.organizationId,
provider: data.provider,
userId: data.userId,
};
switch (event) {
case 'attempt':
// Only track as breadcrumb, not as event
SentryNestJSService.addBreadcrumb(
`Post publishing attempt for ${data.provider}`,
'post.attempt',
baseContext
);
break;
case 'success':
// Only track as breadcrumb, not as event - we don't need to alert on success
SentryNestJSService.addBreadcrumb(
`Post published successfully to ${data.provider}`,
'post.success',
baseContext
);
break;
case 'failed':
// This is an actual error - capture it
SentryNestJSService.captureException(data.error || new Error('Post publishing failed'), {
extra: {
...baseContext,
metadata: data.metadata,
},
tags: {
event: 'post_failed',
provider: data.provider,
},
level: 'error',
});
break;
}
}
/**
* Track integration connection issues
*/
trackIntegrationEvent(event: 'connected' | 'disconnected' | 'failed' | 'refresh_needed', data: {
integrationId: string;
organizationId: string;
userId?: string;
provider: string;
error?: any;
}) {
const baseContext = {
integrationId: data.integrationId,
organizationId: data.organizationId,
provider: data.provider,
userId: data.userId,
};
switch (event) {
case 'connected':
// Only track as breadcrumb - successful connections are not errors
SentryNestJSService.addBreadcrumb(
`Integration connected: ${data.provider}`,
'integration.connected',
baseContext
);
break;
case 'disconnected':
// Only track as breadcrumb unless it's unexpected
SentryNestJSService.addBreadcrumb(
`Integration disconnected: ${data.provider}`,
'integration.disconnected',
baseContext
);
break;
case 'failed':
// This is an actual error - capture it
SentryNestJSService.captureException(data.error || new Error('Integration failed'), {
extra: baseContext,
tags: {
event: 'integration_failed',
provider: data.provider,
},
level: 'error',
});
break;
case 'refresh_needed':
// This is a warning-level issue that needs attention
SentryNestJSService.captureMessage(`Integration needs refresh: ${data.provider}`, 'warning', {
extra: baseContext,
tags: {
event: 'integration_refresh_needed',
provider: data.provider,
},
});
break;
}
}
/**
* Track user authentication events
*/
trackAuthEvent(event: 'login' | 'logout' | 'failed_login' | 'registration', data: {
userId?: string;
email?: string;
provider?: string;
error?: any;
ip?: string;
userAgent?: string;
}) {
const baseContext = {
userId: data.userId,
email: data.email,
provider: data.provider,
ip: data.ip,
userAgent: data.userAgent,
};
switch (event) {
case 'login':
// Set user context but don't create an event - successful logins are not errors
SentryNestJSService.setUser({
id: data.userId,
email: data.email,
});
SentryNestJSService.addBreadcrumb(
'User logged in',
'auth.login',
baseContext
);
break;
case 'logout':
// Just a breadcrumb - logouts are not errors
SentryNestJSService.addBreadcrumb(
'User logged out',
'auth.logout',
baseContext
);
break;
case 'failed_login':
// This is a security issue - capture it, but at warning level unless it's suspicious
SentryNestJSService.captureMessage('Failed login attempt', 'warning', {
extra: baseContext,
tags: {
event: 'failed_login',
provider: data.provider || 'local',
},
});
break;
case 'registration':
// Just a breadcrumb - registrations are not errors
SentryNestJSService.addBreadcrumb(
'User registered',
'auth.registration',
baseContext
);
break;
}
}
/**
* Track API rate limiting events
*/
trackRateLimitEvent(data: {
endpoint: string;
userId?: string;
organizationId?: string;
ip?: string;
limit: number;
remaining: number;
}) {
SentryNestJSService.captureMessage('API rate limit exceeded', 'warning', {
extra: data,
tags: {
event: 'rate_limit_exceeded',
endpoint: data.endpoint,
},
});
}
/**
* Track worker job failures
*/
trackWorkerJobEvent(event: 'started' | 'completed' | 'failed' | 'stalled', data: {
jobName: string;
jobId: string;
organizationId?: string;
error?: any;
duration?: number;
attemptNumber?: number;
}) {
const baseContext = {
jobName: data.jobName,
jobId: data.jobId,
organizationId: data.organizationId,
duration: data.duration,
attemptNumber: data.attemptNumber,
};
switch (event) {
case 'started':
// Just breadcrumb - job starting is not an error
SentryNestJSService.addBreadcrumb(
`Worker job started: ${data.jobName}`,
'worker.started',
baseContext
);
break;
case 'completed':
// Just breadcrumb - successful completion is not an error
SentryNestJSService.addBreadcrumb(
`Worker job completed: ${data.jobName}`,
'worker.completed',
baseContext
);
break;
case 'failed':
// This is an actual error - capture it
SentryNestJSService.captureException(data.error || new Error('Worker job failed'), {
extra: baseContext,
tags: {
event: 'worker_job_failed',
jobName: data.jobName,
},
level: 'error',
});
break;
case 'stalled':
// This is a performance/reliability issue - capture as warning
SentryNestJSService.captureMessage(`Worker job stalled: ${data.jobName}`, 'warning', {
extra: baseContext,
tags: {
event: 'worker_job_stalled',
jobName: data.jobName,
},
});
break;
}
}
/**
* Track database connection issues
*/
trackDatabaseEvent(event: 'connected' | 'disconnected' | 'error', data: {
database: string;
error?: any;
}) {
switch (event) {
case 'connected':
// Just breadcrumb - successful connections are not errors
SentryNestJSService.addBreadcrumb(
`Database connected: ${data.database}`,
'database.connected',
{ database: data.database }
);
break;
case 'disconnected':
// Just breadcrumb - disconnections might be planned
SentryNestJSService.addBreadcrumb(
`Database disconnected: ${data.database}`,
'database.disconnected',
{ database: data.database }
);
break;
case 'error':
// This is an actual error - capture it
SentryNestJSService.captureException(data.error || new Error('Database error'), {
extra: { database: data.database },
tags: {
event: 'database_error',
database: data.database,
},
level: 'error',
});
break;
}
}
/**
* Track performance issues
*/
trackPerformanceIssue(data: {
operation: string;
duration: number;
threshold: number;
metadata?: any;
}) {
if (data.duration > data.threshold) {
SentryNestJSService.captureMessage(
`Slow operation detected: ${data.operation}`,
'warning',
{
extra: {
operation: data.operation,
duration: data.duration,
threshold: data.threshold,
metadata: data.metadata,
},
tags: {
event: 'slow_operation',
operation: data.operation,
},
}
);
}
}
}

View file

@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { SentryNotificationService } from '@gitroom/nestjs-libraries/services/sentry.notification.service';
@Injectable()
export class SentryWorkerService {
constructor(private _sentryNotificationService: SentryNotificationService) {}
trackJobStart(jobName: string, jobId: string, organizationId?: string) {
this._sentryNotificationService.trackWorkerJobEvent('started', {
jobName,
jobId,
organizationId,
});
}
trackJobComplete(jobName: string, jobId: string, duration: number, organizationId?: string) {
this._sentryNotificationService.trackWorkerJobEvent('completed', {
jobName,
jobId,
organizationId,
duration,
});
}
trackJobFailed(jobName: string, jobId: string, error: any, attemptNumber: number, organizationId?: string) {
this._sentryNotificationService.trackWorkerJobEvent('failed', {
jobName,
jobId,
organizationId,
error,
attemptNumber,
});
}
trackJobStalled(jobName: string, jobId: string, organizationId?: string) {
this._sentryNotificationService.trackWorkerJobEvent('stalled', {
jobName,
jobId,
organizationId,
});
}
}

View file

@ -2,7 +2,7 @@
import {
DetailedHTMLProps,
FC,
forwardRef,
InputHTMLAttributes,
ReactNode,
useEffect,
@ -13,7 +13,8 @@ import { useFormContext, useWatch } from 'react-hook-form';
import interClass from '../helpers/inter.font';
import { TranslatedLabel } from '../translation/translated-label';
export const Input: FC<
export const Input = forwardRef<
HTMLInputElement,
DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> & {
removeError?: boolean;
error?: any;
@ -25,7 +26,7 @@ export const Input: FC<
translationKey?: string;
translationParams?: Record<string, string | number>;
}
> = (props) => {
>((props, ref) => {
const {
label,
icon,
@ -69,11 +70,11 @@ export const Input: FC<
>
{icon && <div className="ps-[16px]">{icon}</div>}
<input
ref={ref}
className={clsx(
'h-full bg-transparent outline-none flex-1 text-[14px] text-textColor',
icon ? 'pl-[8px] pe-[16px]' : 'px-[16px]'
)}
{...(disableForm ? {} : form.register(props.name))}
{...rest}
/>
</div>
@ -82,4 +83,6 @@ export const Input: FC<
)}
</div>
);
};
});
Input.displayName = 'Input';

View file

@ -74,6 +74,13 @@
"@neynar/react": "^0.9.7",
"@postiz/wallets": "^0.0.1",
"@prisma/client": "^6.5.0",
"@sentry/browser": "^8.47.0",
"@sentry/integrations": "^7.119.2",
"@sentry/nestjs": "^8.47.0",
"@sentry/nextjs": "^8.47.0",
"@sentry/profiling-node": "^8.47.0",
"@sentry/react": "^8.47.0",
"@sentry/tracing": "^7.119.2",
"@solana/wallet-adapter-react": "^0.15.35",
"@solana/wallet-adapter-react-ui": "^0.9.35",
"@swc/helpers": "0.5.13",
@ -251,6 +258,7 @@
"@vitest/ui": "1.6.0",
"autoprefixer": "^10.4.17",
"babel-jest": "29.7.0",
"cross-env": "^7.0.3",
"eslint": "8.57.0",
"eslint-config-next": "15.2.1",
"eslint-config-prettier": "^9.0.0",

1354
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
[program:backend]
directory=/app/apps/backend
command=pnpm start
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
startsecs=10

View file

@ -7,3 +7,4 @@ redirect_stderr=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
startsecs=3
priority=100

View file

@ -0,0 +1,9 @@
[program:cron]
directory=/app/apps/cron
command=pnpm start
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
startsecs=10

View file

@ -0,0 +1,9 @@
[program:frontend]
directory=/app/apps/frontend
command=pnpm start
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
startsecs=10

View file

@ -0,0 +1,10 @@
[program:migrate]
directory=/app
command=pnpm run prisma-db-push
autostart=true
autorestart=false
redirect_stderr=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
startsecs=0
priority=1

View file

@ -0,0 +1,9 @@
[program:workers]
directory=/app/apps/workers
command=pnpm start
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
startsecs=10