feat/sentry
This commit is contained in:
parent
220554d545
commit
cb65e46ef5
41 changed files with 3448 additions and 110 deletions
14
.env.example
14
.env.example
|
|
@ -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=""
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
34
apps/frontend/instrumentation.ts
Normal file
34
apps/frontend/instrumentation.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
243
apps/frontend/src/lib/sentry-tracking.ts
Normal file
243
apps/frontend/src/lib/sentry-tracking.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
48
apps/frontend/src/lib/sentry.ts
Normal file
48
apps/frontend/src/lib/sentry.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
50
apps/frontend/src/lib/tracked-fetch.ts
Normal file
50
apps/frontend/src/lib/tracked-fetch.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
68
docker-compose.yml
Normal 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
|
||||
6
libraries/helpers/src/sentry/browser.ts
Normal file
6
libraries/helpers/src/sentry/browser.ts
Normal 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;
|
||||
9
libraries/helpers/src/sentry/index.ts
Normal file
9
libraries/helpers/src/sentry/index.ts
Normal 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';
|
||||
171
libraries/helpers/src/sentry/sentry.client.ts
Normal file
171
libraries/helpers/src/sentry/sentry.client.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
}
|
||||
60
libraries/helpers/src/sentry/sentry.config.ts
Normal file
60
libraries/helpers/src/sentry/sentry.config.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
159
libraries/helpers/src/sentry/sentry.nestjs.ts
Normal file
159
libraries/helpers/src/sentry/sentry.nestjs.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
190
libraries/helpers/src/sentry/sentry.nextjs.ts
Normal file
190
libraries/helpers/src/sentry/sentry.nextjs.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
}
|
||||
170
libraries/helpers/src/sentry/sentry.react.ts
Normal file
170
libraries/helpers/src/sentry/sentry.react.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
118
libraries/nestjs-libraries/src/services/sentry.interceptor.ts
Normal file
118
libraries/nestjs-libraries/src/services/sentry.interceptor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
1354
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
9
var/docker/supervisord/backend.conf
Normal file
9
var/docker/supervisord/backend.conf
Normal 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
|
||||
|
|
@ -7,3 +7,4 @@ redirect_stderr=true
|
|||
stdout_logfile=/dev/fd/1
|
||||
stdout_logfile_maxbytes=0
|
||||
startsecs=3
|
||||
priority=100
|
||||
|
|
|
|||
9
var/docker/supervisord/cron.conf
Normal file
9
var/docker/supervisord/cron.conf
Normal 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
|
||||
9
var/docker/supervisord/frontend.conf
Normal file
9
var/docker/supervisord/frontend.conf
Normal 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
|
||||
10
var/docker/supervisord/migrate.conf
Normal file
10
var/docker/supervisord/migrate.conf
Normal 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
|
||||
9
var/docker/supervisord/workers.conf
Normal file
9
var/docker/supervisord/workers.conf
Normal 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
|
||||
Loading…
Add table
Reference in a new issue