Add real-time log monitoring and system stats dashboard

Implements a comprehensive monitoring system for the admin interface:

Backend:
- New monitoring package with Redis ring buffer for log storage
- Zerolog MultiWriter to capture logs to Redis
- System stats collection (CPU, memory, disk, goroutines, GC)
- HTTP metrics middleware (request counts, latency, error rates)
- Asynq queue stats for worker process
- WebSocket endpoint for real-time log streaming
- Admin auth middleware now accepts token in query params (for WebSocket)

Frontend:
- New monitoring page with tabs (Overview, Logs, API Stats, Worker Stats)
- Real-time log viewer with level filtering and search
- System stats cards showing CPU, memory, goroutines, uptime
- HTTP endpoint statistics table
- Asynq queue depth visualization
- Enable/disable monitoring toggle in settings

Memory safeguards:
- Max 200 unique endpoints tracked
- Hourly stats reset to prevent unbounded growth
- Max 1000 log entries in ring buffer
- Max 1000 latency samples for P95 calculation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-09 10:26:40 -06:00
parent 12eac24632
commit eb127fda20
31 changed files with 2880 additions and 213 deletions

View File

@@ -14,6 +14,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -25,6 +26,7 @@
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.555.0",
"next": "16.0.5",
"next-themes": "^0.4.6",
@@ -1925,6 +1927,37 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
@@ -3921,6 +3954,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -15,6 +15,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -26,6 +27,7 @@
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.555.0",
"next": "16.0.5",
"next-themes": "^0.4.6",

View File

@@ -0,0 +1,147 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { RefreshCw, AlertCircle, Settings } from 'lucide-react';
import { toast } from 'sonner';
import {
StatsCards,
SystemOverview,
LogViewer,
HTTPStatsOverview,
HTTPEndpointStats,
QueueOverview,
QueueDetails,
} from '@/components/monitoring';
import { useMonitoring, useLogStream } from '@/hooks/use-monitoring';
import { settingsApi } from '@/lib/api';
export default function MonitoringPage() {
const [activeTab, setActiveTab] = useState('overview');
const queryClient = useQueryClient();
const { logs: initialLogs, stats, isLoading, logsError, statsError, refetchLogs, refetchStats, clearLogs, isClearingLogs } = useMonitoring();
const { logs: liveLogs, status: wsStatus, clearLogs: clearLiveLogs } = useLogStream(activeTab === 'logs');
// Fetch monitoring enabled setting
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: settingsApi.get,
});
// Mutation to update monitoring setting
const updateMonitoringMutation = useMutation({
mutationFn: (enabled: boolean) => settingsApi.update({ enable_monitoring: enabled }),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
toast.success(data.enable_monitoring ? 'Monitoring enabled' : 'Monitoring disabled');
},
onError: () => {
toast.error('Failed to update monitoring setting');
},
});
// Combine live logs with initial logs (live logs take precedence)
const allLogs = useMemo(() => {
const initial = initialLogs?.logs || [];
return [...liveLogs, ...initial].slice(0, 1000);
}, [liveLogs, initialLogs?.logs]);
const handleRefresh = () => {
refetchLogs();
refetchStats();
};
const handleClearLogs = () => {
clearLogs();
clearLiveLogs();
};
const handleMonitoringToggle = () => {
if (settings) {
updateMonitoringMutation.mutate(!settings.enable_monitoring);
}
};
const hasError = logsError || statsError;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">System Monitoring</h1>
<p className="text-muted-foreground">
Real-time logs and system statistics
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
id="monitoring-toggle"
checked={settings?.enable_monitoring ?? true}
onCheckedChange={handleMonitoringToggle}
disabled={updateMonitoringMutation.isPending}
/>
<Label htmlFor="monitoring-toggle" className="text-sm">
{settings?.enable_monitoring ? 'Enabled' : 'Disabled'}
</Label>
</div>
<Button onClick={handleRefresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
{hasError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Connection Error</AlertTitle>
<AlertDescription>
Unable to connect to the monitoring service. Make sure the API server is running with Redis connected.
</AlertDescription>
</Alert>
)}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="api">API Stats</TabsTrigger>
<TabsTrigger value="worker">Worker Stats</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<SystemOverview apiStats={stats?.api} workerStats={stats?.worker} />
<StatsCards apiStats={stats?.api} workerStats={stats?.worker} />
</TabsContent>
<TabsContent value="logs">
<LogViewer
logs={allLogs}
wsStatus={wsStatus}
isLoading={isLoading}
onClearLogs={handleClearLogs}
onRefresh={() => refetchLogs()}
isClearingLogs={isClearingLogs}
/>
</TabsContent>
<TabsContent value="api" className="space-y-4">
<HTTPStatsOverview stats={stats?.api?.http} />
<HTTPEndpointStats stats={stats?.api?.http} />
</TabsContent>
<TabsContent value="worker" className="space-y-4">
<QueueOverview stats={stats?.worker?.asynq} />
<QueueDetails stats={stats?.worker?.asynq} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import {
Activity,
Home,
Users,
Building2,
@@ -75,6 +76,7 @@ const limitationsItems = [
];
const settingsItems = [
{ title: 'Monitoring', url: '/admin/monitoring', icon: Activity },
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
{ title: 'Task Templates', url: '/admin/task-templates', icon: LayoutTemplate },
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },

View File

@@ -0,0 +1,150 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { HTTPStats } from '@/types/monitoring';
import { formatLatency, formatPercent } from '@/hooks/use-monitoring';
import { Activity, AlertTriangle, Clock, Gauge } from 'lucide-react';
interface HTTPStatsProps {
stats?: HTTPStats;
}
export function HTTPStatsOverview({ stats }: HTTPStatsProps) {
if (!stats) {
return (
<Card>
<CardHeader>
<CardTitle>HTTP Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-4 text-muted-foreground">
No HTTP statistics available
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Requests</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{(stats.total_requests ?? 0).toLocaleString()}</div>
<p className="text-xs text-muted-foreground">
{(stats.requests_per_second ?? 0).toFixed(2)} req/s
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Latency</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatLatency(stats.avg_latency_ms)}</div>
<p className="text-xs text-muted-foreground">
P95: {formatLatency(stats.p95_latency_ms)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Error Rate</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatPercent(stats.error_rate)}</div>
<p className="text-xs text-muted-foreground">
{(stats.errors_total ?? 0).toLocaleString()} total errors
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Throughput</CardTitle>
<Gauge className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{(stats.requests_per_second ?? 0).toFixed(2)}</div>
<p className="text-xs text-muted-foreground">requests per second</p>
</CardContent>
</Card>
</div>
);
}
export function HTTPEndpointStats({ stats }: HTTPStatsProps) {
if (!stats?.by_endpoint || Object.keys(stats.by_endpoint).length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Endpoint Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-4 text-muted-foreground">
No endpoint statistics available
</div>
</CardContent>
</Card>
);
}
// Sort endpoints by request count (descending)
const sortedEndpoints = Object.entries(stats.by_endpoint)
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 20); // Show top 20
return (
<Card>
<CardHeader>
<CardTitle>Top Endpoints by Traffic</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Endpoint</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Avg Latency</TableHead>
<TableHead className="text-right">Errors</TableHead>
<TableHead className="text-right">Error Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedEndpoints.map(([endpoint, data]) => {
const errorRate = data.count > 0 ? (data.errors / data.count) * 100 : 0;
return (
<TableRow key={endpoint}>
<TableCell className="font-mono text-sm">{endpoint}</TableCell>
<TableCell className="text-right">{data.count.toLocaleString()}</TableCell>
<TableCell className="text-right">{formatLatency(data.avg_latency_ms)}</TableCell>
<TableCell className="text-right">{data.errors}</TableCell>
<TableCell className="text-right">
<span className={errorRate > 5 ? 'text-red-500' : ''}>
{formatPercent(errorRate)}
</span>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,4 @@
export { StatsCards, SystemOverview } from './stats-cards';
export { LogViewer } from './log-viewer';
export { HTTPStatsOverview, HTTPEndpointStats } from './http-stats';
export { QueueOverview, QueueDetails } from './queue-stats';

View File

@@ -0,0 +1,257 @@
'use client';
import { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Trash2, Wifi, WifiOff, RefreshCw, Search, X } from 'lucide-react';
import type { LogEntry, LogLevel } from '@/types/monitoring';
import { LOG_LEVEL_BG_COLORS, LOG_LEVELS } from '@/types/monitoring';
import type { WSStatus } from '@/hooks/use-monitoring';
import { formatDistanceToNow } from 'date-fns';
interface LogViewerProps {
logs: LogEntry[];
wsStatus: WSStatus;
isLoading?: boolean;
onClearLogs?: () => void;
onRefresh?: () => void;
isClearingLogs?: boolean;
}
export function LogViewer({
logs,
wsStatus,
isLoading,
onClearLogs,
onRefresh,
isClearingLogs,
}: LogViewerProps) {
const [levelFilter, setLevelFilter] = useState<string>('all');
const [processFilter, setProcessFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null);
const filteredLogs = useMemo(() => {
return logs.filter((log) => {
if (levelFilter !== 'all' && log.level !== levelFilter) return false;
if (processFilter !== 'all' && log.process !== processFilter) return false;
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesMessage = log.message.toLowerCase().includes(query);
const matchesCaller = log.caller?.toLowerCase().includes(query);
const matchesFields = JSON.stringify(log.fields).toLowerCase().includes(query);
if (!matchesMessage && !matchesCaller && !matchesFields) return false;
}
return true;
});
}, [logs, levelFilter, processFilter, searchQuery]);
const wsStatusBadge = () => {
switch (wsStatus) {
case 'connected':
return (
<Badge variant="default" className="bg-green-500">
<Wifi className="h-3 w-3 mr-1" />
Live
</Badge>
);
case 'connecting':
return (
<Badge variant="secondary">
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
Connecting
</Badge>
);
case 'error':
return (
<Badge variant="destructive">
<WifiOff className="h-3 w-3 mr-1" />
Error
</Badge>
);
default:
return (
<Badge variant="outline">
<WifiOff className="h-3 w-3 mr-1" />
Disconnected
</Badge>
);
}
};
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
Logs
{wsStatusBadge()}
<Badge variant="outline">{filteredLogs.length} entries</Badge>
</CardTitle>
<div className="flex items-center gap-2">
{onRefresh && (
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
)}
{onClearLogs && (
<Button
variant="outline"
size="sm"
onClick={onClearLogs}
disabled={isClearingLogs}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex flex-wrap gap-2 mt-4">
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Levels</SelectItem>
{LOG_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{level.charAt(0).toUpperCase() + level.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={processFilter} onValueChange={setProcessFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Process" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="api">API</SelectItem>
<SelectItem value="worker">Worker</SelectItem>
</SelectContent>
</Select>
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1 h-6 w-6 p-0"
onClick={() => setSearchQuery('')}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[500px]">
<div className="space-y-1">
{filteredLogs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No logs to display
</div>
) : (
filteredLogs.map((log) => (
<div
key={log.id}
className="flex items-start gap-2 p-2 rounded hover:bg-muted cursor-pointer text-sm font-mono"
onClick={() => setSelectedLog(log)}
>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
</span>
<Badge
variant="secondary"
className={`${LOG_LEVEL_BG_COLORS[log.level as LogLevel] || ''} text-xs`}
>
{log.level.toUpperCase()}
</Badge>
<Badge variant="outline" className="text-xs">
{log.process}
</Badge>
<span className="truncate flex-1">{log.message}</span>
</div>
))
)}
</div>
</ScrollArea>
</CardContent>
</Card>
<Dialog open={!!selectedLog} onOpenChange={() => setSelectedLog(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Log Details
{selectedLog && (
<Badge
variant="secondary"
className={LOG_LEVEL_BG_COLORS[selectedLog.level as LogLevel] || ''}
>
{selectedLog.level.toUpperCase()}
</Badge>
)}
</DialogTitle>
</DialogHeader>
{selectedLog && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Timestamp:</span>
<p className="font-mono">{new Date(selectedLog.timestamp).toISOString()}</p>
</div>
<div>
<span className="text-muted-foreground">Process:</span>
<p className="font-mono">{selectedLog.process}</p>
</div>
<div className="col-span-2">
<span className="text-muted-foreground">Caller:</span>
<p className="font-mono">{selectedLog.caller || 'N/A'}</p>
</div>
</div>
<div>
<span className="text-sm text-muted-foreground">Message:</span>
<p className="font-mono p-2 bg-muted rounded mt-1">{selectedLog.message}</p>
</div>
{selectedLog.fields && Object.keys(selectedLog.fields).length > 0 && (
<div>
<span className="text-sm text-muted-foreground">Fields:</span>
<pre className="font-mono text-xs p-2 bg-muted rounded mt-1 overflow-x-auto">
{JSON.stringify(selectedLog.fields, null, 2)}
</pre>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,189 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { AsynqStats } from '@/types/monitoring';
import { Clock, AlertTriangle, CheckCircle, PlayCircle, RotateCcw, Archive } from 'lucide-react';
interface QueueStatsProps {
stats?: AsynqStats;
}
export function QueueOverview({ stats }: QueueStatsProps) {
if (!stats) {
return (
<Card>
<CardHeader>
<CardTitle>Queue Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-4 text-muted-foreground">
No queue statistics available
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4 md:grid-cols-5">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total_pending}</div>
<p className="text-xs text-muted-foreground">tasks waiting</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active</CardTitle>
<PlayCircle className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-500">{stats.total_active}</div>
<p className="text-xs text-muted-foreground">currently running</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Scheduled</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total_scheduled}</div>
<p className="text-xs text-muted-foreground">future tasks</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Retry</CardTitle>
<RotateCcw className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-500">{stats.total_retry}</div>
<p className="text-xs text-muted-foreground">awaiting retry</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Failed</CardTitle>
<AlertTriangle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-500">{stats.total_failed}</div>
<p className="text-xs text-muted-foreground">permanently failed</p>
</CardContent>
</Card>
</div>
);
}
export function QueueDetails({ stats }: QueueStatsProps) {
if (!stats?.queues || Object.keys(stats.queues).length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Queue Details</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-4 text-muted-foreground">
No queue details available
</div>
</CardContent>
</Card>
);
}
// Sort queues by priority (critical first)
const priorityOrder = ['critical', 'default', 'low'];
const sortedQueues = Object.entries(stats.queues).sort((a, b) => {
const aIndex = priorityOrder.indexOf(a[0]);
const bIndex = priorityOrder.indexOf(b[0]);
if (aIndex === -1 && bIndex === -1) return a[0].localeCompare(b[0]);
if (aIndex === -1) return 1;
if (bIndex === -1) return -1;
return aIndex - bIndex;
});
const getPriorityBadge = (queueName: string) => {
switch (queueName) {
case 'critical':
return <Badge variant="destructive">Critical</Badge>;
case 'default':
return <Badge variant="default">Default</Badge>;
case 'low':
return <Badge variant="secondary">Low</Badge>;
default:
return <Badge variant="outline">{queueName}</Badge>;
}
};
return (
<Card>
<CardHeader>
<CardTitle>Queue Details</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Queue</TableHead>
<TableHead className="text-right">Size</TableHead>
<TableHead className="text-right">Pending</TableHead>
<TableHead className="text-right">Active</TableHead>
<TableHead className="text-right">Scheduled</TableHead>
<TableHead className="text-right">Retry</TableHead>
<TableHead className="text-right">Archived</TableHead>
<TableHead className="text-right">Completed</TableHead>
<TableHead className="text-right">Failed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedQueues.map(([queueName, queue]) => (
<TableRow key={queueName}>
<TableCell>{getPriorityBadge(queueName)}</TableCell>
<TableCell className="text-right">{queue.size}</TableCell>
<TableCell className="text-right">{queue.pending}</TableCell>
<TableCell className="text-right">
<span className={queue.active > 0 ? 'text-blue-500 font-medium' : ''}>
{queue.active}
</span>
</TableCell>
<TableCell className="text-right">{queue.scheduled}</TableCell>
<TableCell className="text-right">
<span className={queue.retry > 0 ? 'text-yellow-500' : ''}>
{queue.retry}
</span>
</TableCell>
<TableCell className="text-right">{queue.archived}</TableCell>
<TableCell className="text-right">
<span className="text-green-500">{queue.completed}</span>
</TableCell>
<TableCell className="text-right">
<span className={queue.failed > 0 ? 'text-red-500 font-medium' : ''}>
{queue.failed}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { SystemStats } from '@/types/monitoring';
import { formatBytes, formatUptime, formatPercent, safeToFixed } from '@/hooks/use-monitoring';
import { Activity, Cpu, HardDrive, Server, Timer, Zap } from 'lucide-react';
interface StatsCardsProps {
apiStats?: SystemStats;
workerStats?: SystemStats;
}
interface ProcessCardProps {
stats: SystemStats;
title: string;
}
function ProcessCard({ stats, title }: ProcessCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Badge variant="default" className="bg-green-500">Online</Badge>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Cpu className="h-3 w-3" />
CPU
</div>
<p className="text-lg font-semibold">{formatPercent(stats.cpu?.usage_percent)}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Server className="h-3 w-3" />
Memory
</div>
<p className="text-lg font-semibold">{formatBytes(stats.memory?.go_alloc)}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Activity className="h-3 w-3" />
Goroutines
</div>
<p className="text-lg font-semibold">{stats.runtime?.goroutines ?? 0}</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Timer className="h-3 w-3" />
Uptime
</div>
<p className="text-lg font-semibold">{formatUptime(stats.runtime?.uptime_seconds)}</p>
</div>
</div>
</CardContent>
</Card>
);
}
function OfflineCard({ title }: { title: string }) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Badge variant="destructive">Offline</Badge>
</CardHeader>
<CardContent>
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</CardContent>
</Card>
);
}
export function StatsCards({ apiStats, workerStats }: StatsCardsProps) {
return (
<div className="grid gap-4 md:grid-cols-2">
{apiStats ? (
<ProcessCard stats={apiStats} title="API Server" />
) : (
<OfflineCard title="API Server" />
)}
{workerStats ? (
<ProcessCard stats={workerStats} title="Worker" />
) : (
<OfflineCard title="Worker" />
)}
</div>
);
}
interface SystemOverviewProps {
apiStats?: SystemStats;
workerStats?: SystemStats;
}
export function SystemOverview({ apiStats, workerStats }: SystemOverviewProps) {
const stats = apiStats || workerStats;
if (!stats) return null;
return (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">System CPU</CardTitle>
<Cpu className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatPercent(stats.cpu?.usage_percent)}</div>
<p className="text-xs text-muted-foreground">
Load: {safeToFixed(stats.cpu?.load_avg_1)} / {safeToFixed(stats.cpu?.load_avg_5)} / {safeToFixed(stats.cpu?.load_avg_15)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">System Memory</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatPercent(stats.memory?.system_percent)}</div>
<p className="text-xs text-muted-foreground">
{formatBytes(stats.memory?.system_used)} / {formatBytes(stats.memory?.system_total)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Disk Usage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{formatPercent(stats.disk?.percent)}</div>
<p className="text-xs text-muted-foreground">
{formatBytes(stats.disk?.used)} / {formatBytes(stats.disk?.total)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">GC Runs</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{(apiStats?.runtime.gc_runs || 0) + (workerStats?.runtime.gc_runs || 0)}</div>
<p className="text-xs text-muted-foreground">
Total pause: {((apiStats?.runtime.gc_pause_total_ns || 0) + (workerStats?.runtime.gc_pause_total_ns || 0)) / 1000000}ms
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,230 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, useCallback, useRef } from 'react';
import { monitoringApi } from '@/lib/api';
import type { LogEntry, LogFilters, LogsResponse, StatsResponse, SystemStats } from '@/types/monitoring';
// Query keys
export const monitoringKeys = {
all: ['monitoring'] as const,
logs: (filters?: LogFilters) => [...monitoringKeys.all, 'logs', filters] as const,
stats: () => [...monitoringKeys.all, 'stats'] as const,
};
// Hook for fetching logs
export function useLogs(filters?: LogFilters) {
return useQuery({
queryKey: monitoringKeys.logs(filters),
queryFn: () => monitoringApi.getLogs(filters),
refetchInterval: 10000, // Refetch every 10 seconds
});
}
// Hook for fetching stats
export function useStats() {
return useQuery({
queryKey: monitoringKeys.stats(),
queryFn: () => monitoringApi.getStats(),
refetchInterval: 5000, // Refetch every 5 seconds
});
}
// Hook for clearing logs
export function useClearLogs() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => monitoringApi.clearLogs(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: monitoringKeys.logs() });
},
});
}
// WebSocket connection status
export type WSStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
// Hook for real-time log streaming via WebSocket
export function useLogStream(enabled: boolean = true) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [status, setStatus] = useState<WSStatus>('disconnected');
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const enabledRef = useRef(enabled);
const maxLogs = 500; // Keep at most 500 logs in memory
// Keep enabledRef in sync
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
const disconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setStatus('disconnected');
}, []);
const connect = useCallback(() => {
// Only run in browser
if (typeof window === 'undefined') return;
if (!enabledRef.current) return;
// Clean up existing connection
if (wsRef.current) {
wsRef.current.close();
}
setStatus('connecting');
try {
const wsUrl = monitoringApi.getWebSocketUrl();
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
setStatus('connected');
// Clear reconnect timeout
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
if (message.type === 'log' && message.data) {
const newLog = message.data as LogEntry;
setLogs((prev) => {
// Add new log at the beginning, limit total logs
const updated = [newLog, ...prev].slice(0, maxLogs);
return updated;
});
}
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
}
};
ws.onerror = () => {
setStatus('error');
};
ws.onclose = () => {
setStatus('disconnected');
wsRef.current = null;
// Attempt to reconnect after 3 seconds
if (enabledRef.current && !reconnectTimeoutRef.current) {
reconnectTimeoutRef.current = setTimeout(() => {
reconnectTimeoutRef.current = null;
connect();
}, 3000);
}
};
wsRef.current = ws;
} catch (err) {
console.error('Failed to create WebSocket:', err);
setStatus('error');
}
}, []);
const clearLogs = useCallback(() => {
setLogs([]);
}, []);
useEffect(() => {
if (enabled) {
connect();
} else {
disconnect();
}
return () => {
disconnect();
};
}, [enabled, connect, disconnect]);
return {
logs,
status,
connect,
disconnect,
clearLogs,
};
}
// Combined hook for monitoring page
export function useMonitoring(filters?: LogFilters) {
const { data: logsData, isLoading: logsLoading, error: logsError, refetch: refetchLogs } = useLogs(filters);
const { data: statsData, isLoading: statsLoading, error: statsError, refetch: refetchStats } = useStats();
const clearLogsMutation = useClearLogs();
return {
logs: logsData,
stats: statsData,
isLoading: logsLoading || statsLoading,
logsError,
statsError,
refetchLogs,
refetchStats,
clearLogs: clearLogsMutation.mutate,
isClearingLogs: clearLogsMutation.isPending,
};
}
// Utility to format bytes to human-readable
export function formatBytes(bytes: number | undefined | null): string {
if (bytes == null || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
// Utility to format uptime
export function formatUptime(seconds: number | undefined | null): string {
if (seconds == null) return '0s';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (parts.length === 0) parts.push(`${Math.floor(seconds)}s`);
return parts.join(' ');
}
// Utility to format percentage
export function formatPercent(value: number | undefined | null): string {
if (value == null) return '0%';
return `${value.toFixed(1)}%`;
}
// Utility to format latency
export function formatLatency(ms: number | undefined | null): string {
if (ms == null) return '0ms';
if (ms < 1) {
return `${(ms * 1000).toFixed(0)}µs`;
}
if (ms < 1000) {
return `${ms.toFixed(1)}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
}
// Safe toFixed helper
export function safeToFixed(value: number | undefined | null, digits: number = 2): string {
if (value == null) return '0';
return value.toFixed(digits);
}

View File

@@ -665,10 +665,12 @@ export const notificationPrefsApi = {
// Settings types
export interface SystemSettings {
enable_limitations: boolean;
enable_monitoring: boolean;
}
export interface UpdateSettingsRequest {
enable_limitations?: boolean;
enable_monitoring?: boolean;
}
// Settings API
@@ -1598,4 +1600,41 @@ export const onboardingEmailsApi = {
},
};
// Monitoring Types
import type { LogEntry, LogFilters, LogsResponse, StatsResponse } from '@/types/monitoring';
// Monitoring API
export const monitoringApi = {
getLogs: async (filters?: LogFilters): Promise<LogsResponse> => {
const response = await api.get<LogsResponse>('/monitoring/logs', { params: filters });
return response.data;
},
getStats: async (): Promise<StatsResponse> => {
const response = await api.get<StatsResponse>('/monitoring/stats');
return response.data;
},
clearLogs: async (): Promise<{ message: string }> => {
const response = await api.delete<{ message: string }>('/monitoring/logs');
return response.data;
},
// WebSocket URL for real-time log streaming
getWebSocketUrl: (): string => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
const token = typeof window !== 'undefined' ? localStorage.getItem('admin_token') : null;
// Convert http(s) to ws(s)
let wsBase = baseUrl.replace(/^http/, 'ws');
if (!wsBase) {
// If no base URL, use current host
const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsBase = `${protocol}//${typeof window !== 'undefined' ? window.location.host : 'localhost'}`;
}
return `${wsBase}/api/admin/monitoring/ws?token=${token || ''}`;
},
};
export default api;

View File

@@ -0,0 +1,129 @@
// Monitoring Types
export interface LogEntry {
id: string;
timestamp: string;
level: string; // "debug", "info", "warn", "error"
message: string;
caller: string;
process: string; // "api" or "worker"
fields: Record<string, unknown>;
}
export interface CPUStats {
usage_percent: number;
load_avg_1: number;
load_avg_5: number;
load_avg_15: number;
}
export interface MemoryStats {
system_total: number;
system_used: number;
system_percent: number;
go_alloc: number;
go_total_alloc: number;
go_sys: number;
go_heap_alloc: number;
go_heap_sys: number;
}
export interface DiskStats {
total: number;
used: number;
percent: number;
}
export interface RuntimeStats {
goroutines: number;
gc_runs: number;
gc_pause_ns: number;
gc_pause_total_ns: number;
uptime_seconds: number;
}
export interface EndpointStats {
count: number;
total_latency_ms: number;
avg_latency_ms: number;
errors: number;
}
export interface HTTPStats {
total_requests: number;
requests_per_second: number;
avg_latency_ms: number;
p95_latency_ms: number;
errors_total: number;
error_rate: number;
by_endpoint: Record<string, EndpointStats>;
}
export interface QueueStats {
size: number;
pending: number;
active: number;
scheduled: number;
retry: number;
archived: number;
completed: number;
failed: number;
}
export interface AsynqStats {
queues: Record<string, QueueStats>;
total_pending: number;
total_active: number;
total_scheduled: number;
total_retry: number;
total_failed: number;
}
export interface SystemStats {
timestamp: string;
process: string; // "api" or "worker"
cpu: CPUStats;
memory: MemoryStats;
disk: DiskStats;
runtime: RuntimeStats;
http?: HTTPStats; // API only
asynq?: AsynqStats; // Worker only
}
export interface LogFilters {
level?: string;
process?: string;
search?: string;
limit?: number;
offset?: number;
}
export interface LogsResponse {
logs: LogEntry[];
total: number;
offset: number;
limit: number;
}
export interface StatsResponse {
api?: SystemStats;
worker?: SystemStats;
}
export type LogLevel = "debug" | "info" | "warn" | "error";
export const LOG_LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
export const LOG_LEVEL_COLORS: Record<LogLevel, string> = {
debug: "text-gray-500",
info: "text-blue-500",
warn: "text-yellow-500",
error: "text-red-500",
};
export const LOG_LEVEL_BG_COLORS: Record<LogLevel, string> = {
debug: "bg-gray-100 text-gray-800",
info: "bg-blue-100 text-blue-800",
warn: "bg-yellow-100 text-yellow-800",
error: "bg-red-100 text-red-800",
};

View File

@@ -15,6 +15,7 @@ import (
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/database"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/monitoring"
"github.com/treytartt/casera-api/internal/push"
"github.com/treytartt/casera-api/internal/router"
"github.com/treytartt/casera-api/internal/services"
@@ -29,7 +30,7 @@ func main() {
os.Exit(1)
}
// Initialize logger
// Initialize basic logger first (will be enhanced after Redis connects)
utils.InitLogger(cfg.Server.Debug)
// Initialize i18n
@@ -80,6 +81,27 @@ func main() {
defer cache.Close()
}
// Initialize monitoring service (if Redis is available)
var monitoringService *monitoring.Service
if cache != nil {
monitoringService = monitoring.NewService(monitoring.Config{
Process: "api",
RedisClient: cache.Client(),
DB: db, // Pass database for enable_monitoring setting sync
})
// Reinitialize logger with monitoring writer
utils.InitLoggerWithWriter(cfg.Server.Debug, monitoringService.LogWriter())
// Start stats collection
monitoringService.Start()
defer monitoringService.Stop()
log.Info().
Bool("log_capture_enabled", monitoringService.IsEnabled()).
Msg("Monitoring service initialized")
}
// Initialize email service
var emailService *services.EmailService
log.Info().
@@ -140,6 +162,7 @@ func main() {
PDFService: pdfService,
PushClient: pushClient,
StorageService: storageService,
MonitoringService: monitoringService,
}
r := router.SetupRouter(deps)

View File

@@ -8,10 +8,12 @@ import (
"syscall"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/database"
"github.com/treytartt/casera-api/internal/monitoring"
"github.com/treytartt/casera-api/internal/push"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
@@ -70,6 +72,43 @@ func main() {
log.Fatal().Err(err).Msg("Failed to parse Redis URL")
}
// Initialize monitoring service (if Redis is available)
var monitoringService *monitoring.Service
redisClientOpt, ok := redisOpt.(asynq.RedisClientOpt)
if ok {
redisClient := redis.NewClient(&redis.Options{
Addr: redisClientOpt.Addr,
Password: redisClientOpt.Password,
DB: redisClientOpt.DB,
})
// Verify Redis connection
if err := redisClient.Ping(context.Background()).Err(); err != nil {
log.Warn().Err(err).Msg("Failed to connect to Redis for monitoring - monitoring disabled")
} else {
monitoringService = monitoring.NewService(monitoring.Config{
Process: "worker",
RedisClient: redisClient,
DB: db, // Pass database for enable_monitoring setting sync
})
// Reinitialize logger with monitoring writer
utils.InitLoggerWithWriter(cfg.Server.Debug, monitoringService.LogWriter())
// Create Asynq inspector for queue statistics
inspector := asynq.NewInspector(redisOpt)
monitoringService.SetAsynqInspector(inspector)
// Start stats collection
monitoringService.Start()
defer monitoringService.Stop()
log.Info().
Bool("log_capture_enabled", monitoringService.IsEnabled()).
Msg("Monitoring service initialized")
}
}
// Create Asynq server
srv := asynq.NewServer(
redisOpt,

60
go.mod
View File

@@ -9,14 +9,19 @@ require (
github.com/gin-gonic/gin v1.10.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/hibiken/asynq v0.25.1
github.com/jung-kurt/gofpdf v1.16.2
github.com/nicksnyder/go-i18n/v2 v2.6.0
github.com/redis/go-redis/v9 v9.17.1
github.com/rs/zerolog v1.34.0
github.com/shirou/gopsutil/v3 v3.24.5
github.com/shopspring/decimal v1.4.0
github.com/sideshow/apns2 v0.25.0
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.40.0
golang.org/x/text v0.27.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
@@ -24,47 +29,22 @@ require (
)
require (
cel.dev/expr v0.23.1 // indirect
cloud.google.com/go v0.121.0 // indirect
cloud.google.com/go/auth v0.16.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/firestore v1.18.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/longrunning v0.6.7 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
cloud.google.com/go/storage v1.53.0 // indirect
firebase.google.com/go/v4 v4.18.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -74,50 +54,34 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nicksnyder/go-i18n/v2 v2.6.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sideshow/apns2 v0.25.0 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.11.0 // indirect
google.golang.org/api v0.231.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/grpc v1.72.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

158
go.sum
View File

@@ -1,33 +1,5 @@
cel.dev/expr v0.23.1 h1:K4KOtPCJQjVggkARsjG9RWXP6O4R73aHeJMa/dmCQQg=
cel.dev/expr v0.23.1/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg=
cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q=
cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/monitoring v1.24.2 h1:5OTsoJ1dXYIiMiuL+sYscLc9BumrL3CarVLL7dd7lHM=
cloud.google.com/go/monitoring v1.24.2/go.mod h1:x7yzPWcgDRnPEv3sI+jJGBkwl5qINf+6qY4eq0I9B4U=
cloud.google.com/go/storage v1.53.0 h1:gg0ERZwL17pJ+Cz3cD2qS60w1WMDnwcm5YPAIQBHUAw=
cloud.google.com/go/storage v1.53.0/go.mod h1:7/eO2a/srr9ImZW9k5uufcNahT2+fPb8w5it1i5boaA=
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
firebase.google.com/go/v4 v4.18.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 h1:6/0iUd0xrnX7qt+mLNRwg5c0PGv8wpE8K90ryANQwMI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -43,20 +15,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls=
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -69,13 +33,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -89,28 +48,19 @@ github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hibiken/asynq v0.25.1 h1:phj028N0nm15n8O2ims+IvJ2gz4k2auvermngh9JhTw=
github.com/hibiken/asynq v0.25.1/go.mod h1:pazWNOLBu0FEynQRBvHA26qdIKRSmfdIfUm4HdsLmXg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -138,6 +88,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -158,16 +110,15 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs=
github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
@@ -175,6 +126,12 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4=
@@ -189,8 +146,6 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE=
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -205,106 +160,49 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA=
go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.231.0 h1:LbUD5FUl0C4qwia2bjXhCMH65yz1MLPzA/0OYEsYY7Q=
google.golang.org/api v0.231.0/go.mod h1:H52180fPI/QQlUc0F4xWfGZILdv09GCWKt2bcsn164A=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 h1:IqsN8hx+lWLqlN+Sc3DoMy/watjofWiU8sRFgQ8fhKM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=

View File

@@ -29,6 +29,7 @@ func NewAdminSettingsHandler(db *gorm.DB) *AdminSettingsHandler {
// SettingsResponse represents the settings response
type SettingsResponse struct {
EnableLimitations bool `json:"enable_limitations"`
EnableMonitoring bool `json:"enable_monitoring"`
}
// GetSettings handles GET /api/admin/settings
@@ -37,7 +38,7 @@ func (h *AdminSettingsHandler) GetSettings(c *gin.Context) {
if err := h.db.First(&settings, 1).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create default settings
settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false}
settings = models.SubscriptionSettings{ID: 1, EnableLimitations: false, EnableMonitoring: true}
h.db.Create(&settings)
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
@@ -47,12 +48,14 @@ func (h *AdminSettingsHandler) GetSettings(c *gin.Context) {
c.JSON(http.StatusOK, SettingsResponse{
EnableLimitations: settings.EnableLimitations,
EnableMonitoring: settings.EnableMonitoring,
})
}
// UpdateSettingsRequest represents the update request
type UpdateSettingsRequest struct {
EnableLimitations *bool `json:"enable_limitations"`
EnableMonitoring *bool `json:"enable_monitoring"`
}
// UpdateSettings handles PUT /api/admin/settings
@@ -66,7 +69,7 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
var settings models.SubscriptionSettings
if err := h.db.First(&settings, 1).Error; err != nil {
if err == gorm.ErrRecordNotFound {
settings = models.SubscriptionSettings{ID: 1}
settings = models.SubscriptionSettings{ID: 1, EnableMonitoring: true}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
return
@@ -77,6 +80,10 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
settings.EnableLimitations = *req.EnableLimitations
}
if req.EnableMonitoring != nil {
settings.EnableMonitoring = *req.EnableMonitoring
}
if err := h.db.Save(&settings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
return
@@ -84,6 +91,7 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
c.JSON(http.StatusOK, SettingsResponse{
EnableLimitations: settings.EnableLimitations,
EnableMonitoring: settings.EnableMonitoring,
})
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/treytartt/casera-api/internal/admin/handlers"
"github.com/treytartt/casera-api/internal/config"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/monitoring"
"github.com/treytartt/casera-api/internal/push"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
@@ -22,6 +23,7 @@ type Dependencies struct {
EmailService *services.EmailService
PushClient *push.Client
OnboardingService *services.OnboardingEmailService
MonitoringHandler *monitoring.Handler
}
// SetupRoutes configures all admin routes
@@ -424,6 +426,17 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
onboardingEmails.GET("/:id", onboardingHandler.Get)
onboardingEmails.DELETE("/:id", onboardingHandler.Delete)
}
// System monitoring (logs, stats, websocket)
if deps != nil && deps.MonitoringHandler != nil {
monitoringGroup := protected.Group("/monitoring")
{
monitoringGroup.GET("/logs", deps.MonitoringHandler.GetLogs)
monitoringGroup.GET("/stats", deps.MonitoringHandler.GetStats)
monitoringGroup.DELETE("/logs", deps.MonitoringHandler.ClearLogs)
monitoringGroup.GET("/ws", deps.MonitoringHandler.WebSocket)
}
}
}
}

View File

@@ -32,21 +32,27 @@ type AdminClaims struct {
// AdminAuthMiddleware creates a middleware that validates admin JWT tokens
func AdminAuthMiddleware(cfg *config.Config, adminRepo *repositories.AdminRepository) gin.HandlerFunc {
return func(c *gin.Context) {
var tokenString string
// Get token from Authorization header
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
return
}
if authHeader != "" {
// Check Bearer prefix
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
return
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
tokenString = parts[1]
}
}
tokenString := parts[1]
// If no header token, check query parameter (for WebSocket connections)
if tokenString == "" {
tokenString = c.Query("token")
}
if tokenString == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"})
return
}
// Parse and validate token
claims := &AdminClaims{}

View File

@@ -16,6 +16,7 @@ const (
type SubscriptionSettings struct {
ID uint `gorm:"primaryKey" json:"id"`
EnableLimitations bool `gorm:"column:enable_limitations;default:false" json:"enable_limitations"`
EnableMonitoring bool `gorm:"column:enable_monitoring;default:true" json:"enable_monitoring"`
}
// TableName returns the table name for GORM

View File

@@ -0,0 +1,165 @@
package monitoring
import (
"context"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
// Redis key constants for monitoring
const (
LogsKey = "monitoring:logs"
LogsChannel = "monitoring:logs:channel"
StatsKeyPrefix = "monitoring:stats:"
MaxLogEntries = 1000
LogsTTL = 24 * time.Hour
StatsExpiration = 30 * time.Second // Stats expire if not updated
)
// LogBuffer provides Redis-backed ring buffer for log entries
type LogBuffer struct {
client *redis.Client
}
// NewLogBuffer creates a new log buffer with the given Redis client
func NewLogBuffer(client *redis.Client) *LogBuffer {
return &LogBuffer{client: client}
}
// Push adds a log entry to the buffer and publishes it for real-time streaming
func (b *LogBuffer) Push(entry LogEntry) error {
ctx := context.Background()
data, err := json.Marshal(entry)
if err != nil {
return err
}
// Use pipeline for atomic operations
pipe := b.client.Pipeline()
// Push to list (ring buffer)
pipe.LPush(ctx, LogsKey, data)
// Trim to max entries
pipe.LTrim(ctx, LogsKey, 0, MaxLogEntries-1)
// Publish for real-time subscribers
pipe.Publish(ctx, LogsChannel, data)
_, err = pipe.Exec(ctx)
return err
}
// GetRecent retrieves the most recent log entries
func (b *LogBuffer) GetRecent(count int) ([]LogEntry, error) {
ctx := context.Background()
if count <= 0 {
count = 100
}
if count > MaxLogEntries {
count = MaxLogEntries
}
results, err := b.client.LRange(ctx, LogsKey, 0, int64(count-1)).Result()
if err != nil {
return nil, err
}
entries := make([]LogEntry, 0, len(results))
for _, r := range results {
var entry LogEntry
if json.Unmarshal([]byte(r), &entry) == nil {
entries = append(entries, entry)
}
}
return entries, nil
}
// Subscribe returns a Redis pubsub channel for real-time log streaming
func (b *LogBuffer) Subscribe(ctx context.Context) *redis.PubSub {
return b.client.Subscribe(ctx, LogsChannel)
}
// Clear removes all logs from the buffer
func (b *LogBuffer) Clear() error {
ctx := context.Background()
return b.client.Del(ctx, LogsKey).Err()
}
// Count returns the number of logs in the buffer
func (b *LogBuffer) Count() (int64, error) {
ctx := context.Background()
return b.client.LLen(ctx, LogsKey).Result()
}
// StatsStore provides Redis storage for system statistics
type StatsStore struct {
client *redis.Client
}
// NewStatsStore creates a new stats store with the given Redis client
func NewStatsStore(client *redis.Client) *StatsStore {
return &StatsStore{client: client}
}
// StoreStats stores system stats for a process
func (s *StatsStore) StoreStats(stats SystemStats) error {
ctx := context.Background()
data, err := json.Marshal(stats)
if err != nil {
return err
}
key := StatsKeyPrefix + stats.Process
return s.client.Set(ctx, key, data, StatsExpiration).Err()
}
// GetStats retrieves stats for a specific process
func (s *StatsStore) GetStats(process string) (*SystemStats, error) {
ctx := context.Background()
key := StatsKeyPrefix + process
data, err := s.client.Get(ctx, key).Bytes()
if err != nil {
if err == redis.Nil {
return nil, nil // No stats available
}
return nil, err
}
var stats SystemStats
if err := json.Unmarshal(data, &stats); err != nil {
return nil, err
}
return &stats, nil
}
// GetAllStats retrieves stats for all processes (api and worker)
func (s *StatsStore) GetAllStats() (map[string]*SystemStats, error) {
result := make(map[string]*SystemStats)
apiStats, err := s.GetStats("api")
if err != nil {
return nil, err
}
if apiStats != nil {
result["api"] = apiStats
}
workerStats, err := s.GetStats("worker")
if err != nil {
return nil, err
}
if workerStats != nil {
result["worker"] = workerStats
}
return result, nil
}

View File

@@ -0,0 +1,199 @@
package monitoring
import (
"runtime"
"time"
"github.com/hibiken/asynq"
"github.com/rs/zerolog/log"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/mem"
)
// Collector gathers system and runtime statistics
type Collector struct {
process string
startTime time.Time
statsStore *StatsStore
httpCollector *HTTPStatsCollector // nil for worker
asynqClient *asynq.Inspector // nil for api
stopChan chan struct{}
}
// NewCollector creates a new stats collector
func NewCollector(process string, statsStore *StatsStore) *Collector {
return &Collector{
process: process,
startTime: time.Now(),
statsStore: statsStore,
stopChan: make(chan struct{}),
}
}
// SetHTTPCollector sets the HTTP stats collector (for API server)
func (c *Collector) SetHTTPCollector(httpCollector *HTTPStatsCollector) {
c.httpCollector = httpCollector
}
// SetAsynqInspector sets the Asynq inspector (for Worker)
func (c *Collector) SetAsynqInspector(inspector *asynq.Inspector) {
c.asynqClient = inspector
}
// Collect gathers all system statistics
func (c *Collector) Collect() SystemStats {
stats := SystemStats{
Timestamp: time.Now().UTC(),
Process: c.process,
}
// CPU stats
c.collectCPU(&stats)
// Memory stats (system + Go runtime)
c.collectMemory(&stats)
// Disk stats
c.collectDisk(&stats)
// Go runtime stats
c.collectRuntime(&stats)
// HTTP stats (API only)
if c.httpCollector != nil {
httpStats := c.httpCollector.GetStats()
stats.HTTP = &httpStats
}
// Asynq stats (Worker only)
if c.asynqClient != nil {
asynqStats := c.collectAsynq()
stats.Asynq = &asynqStats
}
return stats
}
func (c *Collector) collectCPU(stats *SystemStats) {
// Get CPU usage percentage (this blocks for ~100ms to sample)
if cpuPercent, err := cpu.Percent(100*time.Millisecond, false); err == nil && len(cpuPercent) > 0 {
stats.CPU.UsagePercent = cpuPercent[0]
}
stats.CPU.NumCPU = runtime.NumCPU()
// Load averages (Unix only, returns 0 on Windows)
if avg, err := load.Avg(); err == nil {
stats.CPU.LoadAvg1 = avg.Load1
stats.CPU.LoadAvg5 = avg.Load5
stats.CPU.LoadAvg15 = avg.Load15
}
}
func (c *Collector) collectMemory(stats *SystemStats) {
// System memory
if vmem, err := mem.VirtualMemory(); err == nil {
stats.Memory.UsedBytes = vmem.Used
stats.Memory.TotalBytes = vmem.Total
stats.Memory.UsagePercent = vmem.UsedPercent
}
// Go runtime memory
var m runtime.MemStats
runtime.ReadMemStats(&m)
stats.Memory.HeapAlloc = m.HeapAlloc
stats.Memory.HeapSys = m.HeapSys
stats.Memory.HeapInuse = m.HeapInuse
}
func (c *Collector) collectDisk(stats *SystemStats) {
// Root filesystem stats
if diskStat, err := disk.Usage("/"); err == nil {
stats.Disk.UsedBytes = diskStat.Used
stats.Disk.TotalBytes = diskStat.Total
stats.Disk.FreeBytes = diskStat.Free
stats.Disk.UsagePercent = diskStat.UsedPercent
}
}
func (c *Collector) collectRuntime(stats *SystemStats) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
stats.Runtime.Goroutines = runtime.NumGoroutine()
stats.Runtime.NumGC = m.NumGC
if m.NumGC > 0 {
stats.Runtime.LastGCPause = m.PauseNs[(m.NumGC+255)%256]
}
stats.Runtime.Uptime = int64(time.Since(c.startTime).Seconds())
}
func (c *Collector) collectAsynq() AsynqStats {
stats := AsynqStats{
Queues: make(map[string]QueueStats),
}
if c.asynqClient == nil {
return stats
}
queues, err := c.asynqClient.Queues()
if err != nil {
log.Debug().Err(err).Msg("Failed to get asynq queues")
return stats
}
for _, qName := range queues {
info, err := c.asynqClient.GetQueueInfo(qName)
if err != nil {
log.Debug().Err(err).Str("queue", qName).Msg("Failed to get queue info")
continue
}
stats.Queues[qName] = QueueStats{
Pending: info.Pending,
Active: info.Active,
Scheduled: info.Scheduled,
Retry: info.Retry,
Archived: info.Archived,
Completed: info.Completed,
Failed: info.Failed,
}
}
return stats
}
// StartPublishing begins periodic stats collection and publishing to Redis
func (c *Collector) StartPublishing(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Collect immediately on start
c.publishStats()
for {
select {
case <-ticker.C:
c.publishStats()
case <-c.stopChan:
return
}
}
}()
}
func (c *Collector) publishStats() {
stats := c.Collect()
if err := c.statsStore.StoreStats(stats); err != nil {
log.Debug().Err(err).Str("process", c.process).Msg("Failed to publish stats to Redis")
}
}
// Stop stops the stats publishing
func (c *Collector) Stop() {
close(c.stopChan)
}

View File

@@ -0,0 +1,203 @@
package monitoring
import (
"context"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
// Allow connections from admin panel
return true
},
}
// Handler provides HTTP handlers for monitoring endpoints
type Handler struct {
logBuffer *LogBuffer
statsStore *StatsStore
}
// NewHandler creates a new monitoring handler
func NewHandler(logBuffer *LogBuffer, statsStore *StatsStore) *Handler {
return &Handler{
logBuffer: logBuffer,
statsStore: statsStore,
}
}
// GetLogs returns filtered log entries
// GET /api/admin/monitoring/logs
func (h *Handler) GetLogs(c *gin.Context) {
var filters LogFilters
if err := c.ShouldBindQuery(&filters); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid filters"})
return
}
limit := filters.GetLimit()
// Get more entries than requested for filtering
entries, err := h.logBuffer.GetRecent(limit * 2)
if err != nil {
log.Error().Err(err).Msg("Failed to get logs from buffer")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve logs"})
return
}
// Apply filters
filtered := make([]LogEntry, 0, len(entries))
for _, e := range entries {
// Level filter
if filters.Level != "" && e.Level != filters.Level {
continue
}
// Process filter
if filters.Process != "" && e.Process != filters.Process {
continue
}
// Search filter (case-insensitive)
if filters.Search != "" {
searchLower := strings.ToLower(filters.Search)
messageLower := strings.ToLower(e.Message)
if !strings.Contains(messageLower, searchLower) {
continue
}
}
filtered = append(filtered, e)
if len(filtered) >= limit {
break
}
}
c.JSON(http.StatusOK, gin.H{
"logs": filtered,
"total": len(filtered),
})
}
// GetStats returns system statistics for all processes
// GET /api/admin/monitoring/stats
func (h *Handler) GetStats(c *gin.Context) {
allStats, err := h.statsStore.GetAllStats()
if err != nil {
log.Error().Err(err).Msg("Failed to get stats from store")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve stats"})
return
}
c.JSON(http.StatusOK, allStats)
}
// ClearLogs clears all logs from the buffer
// DELETE /api/admin/monitoring/logs
func (h *Handler) ClearLogs(c *gin.Context) {
if err := h.logBuffer.Clear(); err != nil {
log.Error().Err(err).Msg("Failed to clear logs")
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clear logs"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Logs cleared"})
}
// WebSocket handles real-time log streaming
// GET /api/admin/monitoring/ws
func (h *Handler) WebSocket(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Error().Err(err).Msg("Failed to upgrade WebSocket connection")
return
}
defer conn.Close()
// Create context that cancels when connection closes
ctx, cancel := context.WithCancel(c.Request.Context())
defer cancel()
// Subscribe to Redis pubsub for logs
pubsub := h.logBuffer.Subscribe(ctx)
defer pubsub.Close()
// Handle incoming messages (for filter changes, ping, etc.)
var wsMu sync.Mutex
go func() {
for {
_, _, err := conn.ReadMessage()
if err != nil {
cancel()
return
}
}
}()
// Stream logs from pubsub
ch := pubsub.Channel()
statsTicker := time.NewTicker(5 * time.Second)
defer statsTicker.Stop()
// Send initial stats
h.sendStats(conn, &wsMu)
for {
select {
case msg := <-ch:
// Parse log entry
var entry LogEntry
if err := json.Unmarshal([]byte(msg.Payload), &entry); err != nil {
continue
}
// Send log message
wsMsg := WSMessage{
Type: WSMessageTypeLog,
Data: entry,
}
wsMu.Lock()
err := conn.WriteJSON(wsMsg)
wsMu.Unlock()
if err != nil {
log.Debug().Err(err).Msg("WebSocket write error")
return
}
case <-statsTicker.C:
// Send periodic stats update
h.sendStats(conn, &wsMu)
case <-ctx.Done():
return
}
}
}
func (h *Handler) sendStats(conn *websocket.Conn, mu *sync.Mutex) {
allStats, err := h.statsStore.GetAllStats()
if err != nil {
return
}
wsMsg := WSMessage{
Type: WSMessageTypeStats,
Data: allStats,
}
mu.Lock()
conn.WriteJSON(wsMsg)
mu.Unlock()
}

View File

@@ -0,0 +1,215 @@
package monitoring
import (
"sort"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// HTTPStatsCollector collects HTTP request metrics
type HTTPStatsCollector struct {
mu sync.RWMutex
requests map[string]int64 // endpoint -> count
totalLatency map[string]time.Duration // endpoint -> total latency
errors map[string]int64 // endpoint -> error count
byStatus map[int]int64 // status code -> count
latencies []latencySample // recent latency samples for P95
startTime time.Time
lastReset time.Time
}
type latencySample struct {
endpoint string
latency time.Duration
timestamp time.Time
}
const (
maxLatencySamples = 1000
maxEndpoints = 200 // Cap unique endpoints tracked
statsResetPeriod = 1 * time.Hour // Reset stats periodically to prevent unbounded growth
)
// NewHTTPStatsCollector creates a new HTTP stats collector
func NewHTTPStatsCollector() *HTTPStatsCollector {
now := time.Now()
return &HTTPStatsCollector{
requests: make(map[string]int64),
totalLatency: make(map[string]time.Duration),
errors: make(map[string]int64),
byStatus: make(map[int]int64),
latencies: make([]latencySample, 0, maxLatencySamples),
startTime: now,
lastReset: now,
}
}
// Record records a single HTTP request
func (c *HTTPStatsCollector) Record(endpoint string, latency time.Duration, status int) {
c.mu.Lock()
defer c.mu.Unlock()
// Periodically reset to prevent unbounded memory growth
if time.Since(c.lastReset) > statsResetPeriod {
c.resetLocked()
}
// Check if we've hit the endpoint limit and this is a new endpoint
if _, exists := c.requests[endpoint]; !exists && len(c.requests) >= maxEndpoints {
// Use a catch-all bucket for overflow endpoints
endpoint = "OTHER"
}
c.requests[endpoint]++
c.totalLatency[endpoint] += latency
c.byStatus[status]++
if status >= 400 {
c.errors[endpoint]++
}
// Store latency sample
c.latencies = append(c.latencies, latencySample{
endpoint: endpoint,
latency: latency,
timestamp: time.Now(),
})
// Keep only recent samples
if len(c.latencies) > maxLatencySamples {
c.latencies = c.latencies[len(c.latencies)-maxLatencySamples:]
}
}
// resetLocked resets stats while holding the lock
func (c *HTTPStatsCollector) resetLocked() {
c.requests = make(map[string]int64)
c.totalLatency = make(map[string]time.Duration)
c.errors = make(map[string]int64)
c.byStatus = make(map[int]int64)
c.latencies = make([]latencySample, 0, maxLatencySamples)
c.lastReset = time.Now()
// Keep startTime for uptime calculation
}
// GetStats returns the current HTTP statistics
func (c *HTTPStatsCollector) GetStats() HTTPStats {
c.mu.RLock()
defer c.mu.RUnlock()
stats := HTTPStats{
ByEndpoint: make(map[string]EndpointStats),
ByStatusCode: make(map[int]int64),
}
var totalRequests int64
var totalErrors int64
var totalLatency time.Duration
for endpoint, count := range c.requests {
avgLatency := c.totalLatency[endpoint] / time.Duration(count)
errCount := c.errors[endpoint]
errRate := float64(0)
if count > 0 {
errRate = float64(errCount) / float64(count)
}
stats.ByEndpoint[endpoint] = EndpointStats{
Count: count,
AvgLatencyMs: float64(avgLatency.Milliseconds()),
ErrorRate: errRate,
P95LatencyMs: c.calculateP95(endpoint),
}
totalRequests += count
totalErrors += errCount
totalLatency += c.totalLatency[endpoint]
}
// Copy status code counts
for status, count := range c.byStatus {
stats.ByStatusCode[status] = count
}
stats.RequestsTotal = totalRequests
if totalRequests > 0 {
stats.AvgLatencyMs = float64(totalLatency.Milliseconds()) / float64(totalRequests)
stats.ErrorRate = float64(totalErrors) / float64(totalRequests)
}
uptime := time.Since(c.startTime).Minutes()
if uptime > 0 {
stats.RequestsPerMinute = float64(totalRequests) / uptime
}
return stats
}
// calculateP95 calculates the 95th percentile latency for an endpoint
// Must be called with read lock held
func (c *HTTPStatsCollector) calculateP95(endpoint string) float64 {
var endpointLatencies []time.Duration
for _, sample := range c.latencies {
if sample.endpoint == endpoint {
endpointLatencies = append(endpointLatencies, sample.latency)
}
}
if len(endpointLatencies) == 0 {
return 0
}
// Sort latencies
sort.Slice(endpointLatencies, func(i, j int) bool {
return endpointLatencies[i] < endpointLatencies[j]
})
// Calculate P95 index
p95Index := int(float64(len(endpointLatencies)) * 0.95)
if p95Index >= len(endpointLatencies) {
p95Index = len(endpointLatencies) - 1
}
return float64(endpointLatencies[p95Index].Milliseconds())
}
// Reset clears all collected stats
func (c *HTTPStatsCollector) Reset() {
c.mu.Lock()
defer c.mu.Unlock()
c.requests = make(map[string]int64)
c.totalLatency = make(map[string]time.Duration)
c.errors = make(map[string]int64)
c.byStatus = make(map[int]int64)
c.latencies = make([]latencySample, 0, maxLatencySamples)
c.startTime = time.Now()
}
// MetricsMiddleware returns a Gin middleware that collects request metrics
func MetricsMiddleware(collector *HTTPStatsCollector) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
// Process request
c.Next()
// Calculate latency
latency := time.Since(start)
// Get endpoint pattern (use route path, fallback to actual path)
endpoint := c.FullPath()
if endpoint == "" {
endpoint = c.Request.URL.Path
}
// Combine method with path for unique endpoint identification
endpoint = c.Request.Method + " " + endpoint
// Record metrics
collector.Record(endpoint, latency, c.Writer.Status())
}
}

View File

@@ -0,0 +1,128 @@
package monitoring
import "time"
// LogEntry represents a single log entry captured from zerolog
type LogEntry struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"` // debug, info, warn, error, fatal
Message string `json:"message"`
Caller string `json:"caller"` // file:line
Process string `json:"process"` // "api" or "worker"
Fields map[string]any `json:"fields"` // Additional structured fields
}
// SystemStats contains all system and runtime statistics
type SystemStats struct {
Timestamp time.Time `json:"timestamp"`
Process string `json:"process"`
CPU CPUStats `json:"cpu"`
Memory MemoryStats `json:"memory"`
Disk DiskStats `json:"disk"`
Runtime RuntimeStats `json:"runtime"`
HTTP *HTTPStats `json:"http,omitempty"` // API only
Asynq *AsynqStats `json:"asynq,omitempty"` // Worker only
}
// CPUStats contains CPU usage information
type CPUStats struct {
UsagePercent float64 `json:"usage_percent"`
NumCPU int `json:"num_cpu"`
LoadAvg1 float64 `json:"load_avg_1"`
LoadAvg5 float64 `json:"load_avg_5"`
LoadAvg15 float64 `json:"load_avg_15"`
}
// MemoryStats contains both system and Go runtime memory info
type MemoryStats struct {
// System memory
UsedBytes uint64 `json:"used_bytes"`
TotalBytes uint64 `json:"total_bytes"`
UsagePercent float64 `json:"usage_percent"`
// Go heap
HeapAlloc uint64 `json:"heap_alloc"`
HeapSys uint64 `json:"heap_sys"`
HeapInuse uint64 `json:"heap_inuse"`
}
// DiskStats contains disk usage information
type DiskStats struct {
UsedBytes uint64 `json:"used_bytes"`
TotalBytes uint64 `json:"total_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsagePercent float64 `json:"usage_percent"`
}
// RuntimeStats contains Go runtime information
type RuntimeStats struct {
Goroutines int `json:"goroutines"`
NumGC uint32 `json:"num_gc"`
LastGCPause uint64 `json:"last_gc_pause_ns"`
Uptime int64 `json:"uptime_seconds"`
}
// HTTPStats contains HTTP request metrics (API server only)
type HTTPStats struct {
RequestsTotal int64 `json:"requests_total"`
RequestsPerMinute float64 `json:"requests_per_minute"`
AvgLatencyMs float64 `json:"avg_latency_ms"`
ErrorRate float64 `json:"error_rate"`
ByEndpoint map[string]EndpointStats `json:"by_endpoint"`
ByStatusCode map[int]int64 `json:"by_status_code"`
}
// EndpointStats contains per-endpoint HTTP metrics
type EndpointStats struct {
Count int64 `json:"count"`
AvgLatencyMs float64 `json:"avg_latency_ms"`
P95LatencyMs float64 `json:"p95_latency_ms"`
ErrorRate float64 `json:"error_rate"`
}
// AsynqStats contains Asynq job queue metrics (Worker only)
type AsynqStats struct {
Queues map[string]QueueStats `json:"queues"`
}
// QueueStats contains stats for a single Asynq queue
type QueueStats struct {
Pending int `json:"pending"`
Active int `json:"active"`
Scheduled int `json:"scheduled"`
Retry int `json:"retry"`
Archived int `json:"archived"`
Completed int `json:"completed"`
Failed int `json:"failed"`
}
// LogFilters for querying logs
type LogFilters struct {
Level string `form:"level"`
Process string `form:"process"`
Search string `form:"search"`
Limit int `form:"limit,default=100"`
}
// GetLimit returns the limit with bounds checking
func (f *LogFilters) GetLimit() int {
if f.Limit <= 0 {
return 100
}
if f.Limit > 1000 {
return 1000
}
return f.Limit
}
// WebSocket message types
const (
WSMessageTypeLog = "log"
WSMessageTypeStats = "stats"
)
// WSMessage wraps messages sent over WebSocket
type WSMessage struct {
Type string `json:"type"` // "log" or "stats"
Data any `json:"data"`
}

View File

@@ -0,0 +1,194 @@
package monitoring
import (
"io"
"time"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/casera-api/internal/models"
)
const (
// DefaultStatsInterval is the default interval for collecting/publishing stats
DefaultStatsInterval = 5 * time.Second
// SettingsSyncInterval is how often to check the database for enable_monitoring setting
SettingsSyncInterval = 30 * time.Second
)
// Service orchestrates all monitoring components
type Service struct {
process string
logBuffer *LogBuffer
statsStore *StatsStore
collector *Collector
httpCollector *HTTPStatsCollector
handler *Handler
logWriter *RedisLogWriter
db *gorm.DB
settingsStopCh chan struct{}
}
// Config holds configuration for the monitoring service
type Config struct {
Process string // "api" or "worker"
RedisClient *redis.Client // Redis client for log buffer
StatsInterval time.Duration // Interval for stats collection (default 5s)
DB *gorm.DB // Database for checking enable_monitoring setting (optional)
}
// NewService creates a new monitoring service
func NewService(cfg Config) *Service {
if cfg.StatsInterval == 0 {
cfg.StatsInterval = DefaultStatsInterval
}
// Create components
logBuffer := NewLogBuffer(cfg.RedisClient)
statsStore := NewStatsStore(cfg.RedisClient)
collector := NewCollector(cfg.Process, statsStore)
handler := NewHandler(logBuffer, statsStore)
logWriter := NewRedisLogWriter(logBuffer, cfg.Process)
// For API server, create HTTP stats collector
var httpCollector *HTTPStatsCollector
if cfg.Process == "api" {
httpCollector = NewHTTPStatsCollector()
collector.SetHTTPCollector(httpCollector)
}
svc := &Service{
process: cfg.Process,
logBuffer: logBuffer,
statsStore: statsStore,
collector: collector,
httpCollector: httpCollector,
handler: handler,
logWriter: logWriter,
db: cfg.DB,
settingsStopCh: make(chan struct{}),
}
// Check initial setting from database
if cfg.DB != nil {
svc.syncSettingsFromDB()
}
return svc
}
// SetAsynqInspector sets the Asynq inspector for worker stats
func (s *Service) SetAsynqInspector(inspector *asynq.Inspector) {
s.collector.SetAsynqInspector(inspector)
}
// Start begins collecting and publishing stats
func (s *Service) Start() {
log.Info().
Str("process", s.process).
Dur("interval", DefaultStatsInterval).
Bool("enabled", s.logWriter.IsEnabled()).
Msg("Starting monitoring service")
s.collector.StartPublishing(DefaultStatsInterval)
// Start settings sync if database is available
if s.db != nil {
go s.startSettingsSync()
}
}
// Stop stops the monitoring service
func (s *Service) Stop() {
// Stop settings sync
close(s.settingsStopCh)
s.collector.Stop()
log.Info().Str("process", s.process).Msg("Monitoring service stopped")
}
// syncSettingsFromDB checks the database for the enable_monitoring setting
func (s *Service) syncSettingsFromDB() {
if s.db == nil {
return
}
var settings models.SubscriptionSettings
err := s.db.First(&settings, 1).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
// No settings record, default to enabled
s.logWriter.SetEnabled(true)
}
// On other errors, keep current state
return
}
wasEnabled := s.logWriter.IsEnabled()
s.logWriter.SetEnabled(settings.EnableMonitoring)
if wasEnabled != settings.EnableMonitoring {
log.Info().
Str("process", s.process).
Bool("enabled", settings.EnableMonitoring).
Msg("Monitoring log capture setting changed")
}
}
// startSettingsSync periodically checks the database for settings changes
func (s *Service) startSettingsSync() {
ticker := time.NewTicker(SettingsSyncInterval)
defer ticker.Stop()
for {
select {
case <-s.settingsStopCh:
return
case <-ticker.C:
s.syncSettingsFromDB()
}
}
}
// SetEnabled manually enables or disables log capture
func (s *Service) SetEnabled(enabled bool) {
s.logWriter.SetEnabled(enabled)
}
// IsEnabled returns whether log capture is enabled
func (s *Service) IsEnabled() bool {
return s.logWriter.IsEnabled()
}
// SetDB sets the database connection for settings sync
// This can be called after NewService if DB wasn't available during initialization
func (s *Service) SetDB(db *gorm.DB) {
s.db = db
s.syncSettingsFromDB()
}
// LogWriter returns an io.Writer for zerolog that captures logs to Redis
func (s *Service) LogWriter() io.Writer {
return s.logWriter
}
// Handler returns the HTTP handler for monitoring endpoints
func (s *Service) Handler() *Handler {
return s.handler
}
// HTTPCollector returns the HTTP stats collector (nil for worker)
func (s *Service) HTTPCollector() *HTTPStatsCollector {
return s.httpCollector
}
// MetricsMiddleware returns the Gin middleware for HTTP metrics (API server only)
func (s *Service) MetricsMiddleware() interface{} {
if s.httpCollector == nil {
return nil
}
return MetricsMiddleware(s.httpCollector)
}

View File

@@ -0,0 +1,95 @@
package monitoring
import (
"encoding/json"
"sync/atomic"
"time"
"github.com/google/uuid"
)
// RedisLogWriter implements io.Writer to capture zerolog output to Redis
type RedisLogWriter struct {
buffer *LogBuffer
process string
enabled atomic.Bool
}
// NewRedisLogWriter creates a new writer that captures logs to Redis
func NewRedisLogWriter(buffer *LogBuffer, process string) *RedisLogWriter {
w := &RedisLogWriter{
buffer: buffer,
process: process,
}
w.enabled.Store(true) // enabled by default
return w
}
// SetEnabled enables or disables log capture to Redis
func (w *RedisLogWriter) SetEnabled(enabled bool) {
w.enabled.Store(enabled)
}
// IsEnabled returns whether log capture is enabled
func (w *RedisLogWriter) IsEnabled() bool {
return w.enabled.Load()
}
// Write implements io.Writer interface
// It parses zerolog JSON output and writes to Redis asynchronously
func (w *RedisLogWriter) Write(p []byte) (n int, err error) {
// Skip if monitoring is disabled
if !w.enabled.Load() {
return len(p), nil
}
// Parse zerolog JSON output
var raw map[string]any
if err := json.Unmarshal(p, &raw); err != nil {
// Not valid JSON, skip (could be console writer output)
return len(p), nil
}
// Build log entry
entry := LogEntry{
ID: uuid.NewString(),
Timestamp: time.Now().UTC(),
Process: w.process,
Fields: make(map[string]any),
}
// Extract standard zerolog fields
if lvl, ok := raw["level"].(string); ok {
entry.Level = lvl
}
if msg, ok := raw["message"].(string); ok {
entry.Message = msg
}
if caller, ok := raw["caller"].(string); ok {
entry.Caller = caller
}
// Extract timestamp if present (zerolog may include it)
if ts, ok := raw["time"].(string); ok {
if parsed, err := time.Parse(time.RFC3339, ts); err == nil {
entry.Timestamp = parsed
}
}
// Copy additional fields (excluding standard ones)
for k, v := range raw {
switch k {
case "level", "message", "caller", "time":
// Skip standard fields
default:
entry.Fields[k] = v
}
}
// Write to Redis asynchronously to avoid blocking
go func() {
_ = w.buffer.Push(entry) // Ignore errors to avoid blocking log output
}()
return len(p), nil
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/treytartt/casera-api/internal/handlers"
"github.com/treytartt/casera-api/internal/i18n"
"github.com/treytartt/casera-api/internal/middleware"
"github.com/treytartt/casera-api/internal/monitoring"
"github.com/treytartt/casera-api/internal/push"
"github.com/treytartt/casera-api/internal/repositories"
"github.com/treytartt/casera-api/internal/services"
@@ -30,6 +31,7 @@ type Dependencies struct {
PDFService *services.PDFService
PushClient *push.Client // Direct APNs/FCM client
StorageService *services.StorageService
MonitoringService *monitoring.Service
}
// SetupRouter creates and configures the Gin router
@@ -51,6 +53,13 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
r.Use(corsMiddleware(cfg))
r.Use(i18n.Middleware())
// Monitoring metrics middleware (if monitoring is enabled)
if deps.MonitoringService != nil {
if metricsMiddleware := deps.MonitoringService.MetricsMiddleware(); metricsMiddleware != nil {
r.Use(metricsMiddleware.(gin.HandlerFunc))
}
}
// Serve landing page static files (if static directory is configured)
staticDir := cfg.Server.StaticDir
if staticDir != "" {
@@ -137,11 +146,17 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
mediaHandler = handlers.NewMediaHandler(documentRepo, taskRepo, residenceRepo, deps.StorageService)
}
// Set up admin routes (separate auth system)
// Set up admin routes with monitoring handler (if available)
var monitoringHandler *monitoring.Handler
if deps.MonitoringService != nil {
monitoringHandler = deps.MonitoringService.Handler()
}
adminDeps := &admin.Dependencies{
EmailService: deps.EmailService,
PushClient: deps.PushClient,
OnboardingService: onboardingService,
MonitoringHandler: monitoringHandler,
}
admin.SetupRoutes(r, deps.DB, cfg, adminDeps)

View File

@@ -12,20 +12,46 @@ import (
// InitLogger initializes the zerolog logger
func InitLogger(debug bool) {
InitLoggerWithWriter(debug, nil)
}
// InitLoggerWithWriter initializes the zerolog logger with an optional additional writer
// The additional writer receives JSON formatted logs (useful for monitoring)
func InitLoggerWithWriter(debug bool, additionalWriter io.Writer) {
zerolog.TimeFieldFormat = time.RFC3339
var output io.Writer = os.Stdout
if debug {
// Pretty console output for development
zerolog.SetGlobalLevel(zerolog.DebugLevel)
} else {
zerolog.SetGlobalLevel(zerolog.InfoLevel)
}
// Build the output writer(s)
var output io.Writer
if additionalWriter != nil {
// Always write JSON to additional writer for monitoring
// The additional writer parses JSON to extract log entries
if debug {
// In debug mode: pretty console to stdout + JSON to additional writer
consoleOutput := zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: "15:04:05",
}
output = io.MultiWriter(consoleOutput, additionalWriter)
} else {
// In production: JSON to both stdout and additional writer
output = io.MultiWriter(os.Stdout, additionalWriter)
}
} else {
if debug {
output = zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: "15:04:05",
}
zerolog.SetGlobalLevel(zerolog.DebugLevel)
} else {
// JSON output for production
zerolog.SetGlobalLevel(zerolog.InfoLevel)
output = os.Stdout
}
}
log.Logger = zerolog.New(output).With().Timestamp().Caller().Logger()