From 0afcf237e8f912df839d4ec1c41e7b5e1ba1ead4 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 2 Oct 2026 23:42:10 +0200 Subject: [PATCH] feat(api): Protokollzeile je Anfrage und je Nextcloud-Pruefung im Docker-Log Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/api/src/common/request-log.spec.ts | 28 ++++++++++ apps/api/src/common/request-log.ts | 51 +++++++++++++++++++ apps/api/src/main.ts | 9 ++-- .../nextcloud-status-scheduler.service.ts | 5 ++ .../nextcloud-status.service.ts | 7 +++ 5 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/common/request-log.spec.ts create mode 100644 apps/api/src/common/request-log.ts diff --git a/apps/api/src/common/request-log.spec.ts b/apps/api/src/common/request-log.spec.ts new file mode 100644 index 0000000..a6b9bd1 --- /dev/null +++ b/apps/api/src/common/request-log.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { formatRequestLine } from './request-log'; + +describe('formatRequestLine', () => { + it('schreibt Methode, Pfad, Status, Dauer und Benutzer', () => { + expect( + formatRequestLine({ + method: 'GET', + url: '/modules/x', + status: 200, + ms: 12, + username: 'admin', + }), + ).toBe('GET /modules/x 200 12 ms user=admin'); + }); + + it('laesst den Abfrageteil weg', () => { + expect( + formatRequestLine({ method: 'GET', url: '/auth/reset?token=geheim', status: 200, ms: 1 }), + ).toBe('GET /auth/reset 200 1 ms user=-'); + }); + + it('markiert langsame Anfragen', () => { + expect(formatRequestLine({ method: 'POST', url: '/a', status: 201, ms: 4500 })).toContain( + 'LANGSAM', + ); + }); +}); diff --git a/apps/api/src/common/request-log.ts b/apps/api/src/common/request-log.ts new file mode 100644 index 0000000..59caddd --- /dev/null +++ b/apps/api/src/common/request-log.ts @@ -0,0 +1,51 @@ +import { Logger } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; + +/** + * Eine Protokollzeile je API-Anfrage im Docker-Log (quick-261002): + * `GET /modules/nextcloud-status/instances 200 34 ms user=admin`. + * + * Nur Methode, Pfad OHNE Abfrageteil (dort koennten Kennungen oder Token + * stehen), Status, Dauer und Benutzername — nie Koerper, Cookies oder + * Kopfzeilen. `/health` wird ausgelassen, der Docker-Healthcheck fragt es + * alle paar Sekunden ab. Fehler (>= 500) als `error`, Abweisungen (>= 400) + * als `warn`, langsame Anfragen (>= 3 s) mit Vermerk. + */ +const logger = new Logger('HTTP'); +export const SLOW_REQUEST_MS = 3000; + +export function formatRequestLine(input: { + method: string; + url: string; + status: number; + ms: number; + username?: string | null; +}): string { + const path = input.url.split('?')[0] ?? input.url; + const slow = input.ms >= SLOW_REQUEST_MS ? ' LANGSAM' : ''; + return `${input.method} ${path} ${input.status} ${input.ms} ms user=${input.username ?? '-'}${slow}`; +} + +export function requestLogMiddleware(req: Request, res: Response, next: NextFunction): void { + const url = req.originalUrl ?? req.url; + if (url === '/health' || url.startsWith('/health?') || url.startsWith('/health/')) { + next(); + return; + } + const started = process.hrtime.bigint(); + res.on('finish', () => { + const ms = Number((process.hrtime.bigint() - started) / 1_000_000n); + const username = (req as Request & { user?: { username?: string } }).user?.username; + const line = formatRequestLine({ + method: req.method, + url, + status: res.statusCode, + ms, + username, + }); + if (res.statusCode >= 500) logger.error(line); + else if (res.statusCode >= 400 || ms >= SLOW_REQUEST_MS) logger.warn(line); + else logger.log(line); + }); + next(); +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 018d028..40fedb8 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -3,12 +3,16 @@ import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import cookieParser from 'cookie-parser'; import { AppModule } from './app.module'; +import { requestLogMiddleware } from './common/request-log'; import { formatAppVersionLine } from './health/app-version'; async function bootstrap() { const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); + // Eine Zeile je Anfrage im Docker-Log (Pfad, Status, Dauer, Benutzer) + app.use(requestLogMiddleware); + // Cookie parser for JWT httpOnly cookies app.use(cookieParser()); @@ -21,10 +25,7 @@ async function bootstrap() { ); // CORS with credentials for cross-origin cookie support (Pitfall 4) - const corsOrigin = configService.get( - 'CORS_ORIGIN', - 'http://localhost:3000', - ); + const corsOrigin = configService.get('CORS_ORIGIN', 'http://localhost:3000'); app.enableCors({ origin: corsOrigin, credentials: true, diff --git a/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts b/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts index 98ab084..dd5d060 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts @@ -146,6 +146,8 @@ export class NextcloudStatusSchedulerService implements OnApplicationBootstrap { for (const [tenantId, ids] of byTenant) { for (const id of ids) work.push({ tenantId, id }); } + if (work.length === 0) return; + const startedAt = Date.now(); await runWithConcurrency(work, CHECK_CONCURRENCY, async ({ tenantId, id }) => { try { await this.service.checkInstance(tenantId, id); @@ -155,5 +157,8 @@ export class NextcloudStatusSchedulerService implements OnApplicationBootstrap { ); } }); + this.logger.log( + `Nextcloud-Pruefdurchlauf: ${work.length} Cloud(s) in ${Date.now() - startedAt} ms`, + ); } } diff --git a/apps/api/src/nextcloud-status/nextcloud-status.service.ts b/apps/api/src/nextcloud-status/nextcloud-status.service.ts index 2aac054..9e3c6ef 100644 --- a/apps/api/src/nextcloud-status/nextcloud-status.service.ts +++ b/apps/api/src/nextcloud-status/nextcloud-status.service.ts @@ -219,6 +219,7 @@ export class NextcloudStatusService { }); if (!existing) throw new NotFoundException('Cloud nicht gefunden'); + const startedAt = Date.now(); const result = await fetchNextcloudStatus(existing.baseUrl); const now = new Date(); const plan = planStatusWrite(existing.consecutiveFailures, result, now); @@ -228,6 +229,12 @@ export class NextcloudStatusService { select: { ...PUBLIC_SELECT, alertState: true }, }); const view = this.toView(updated as PublicRow, await this.release.getReference()); + const outcome = result.reachable + ? `Version ${result.versionString ?? '?'}${result.maintenance ? ', Wartungsmodus' : ''}` + : `Fehler ${result.errorKind}${result.errorDetail ? ` (${result.errorDetail})` : ''}`; + this.logger.log( + `Pruefung "${updated.customerName}" ${existing.baseUrl}: ${outcome}, Ampel ${view.rating.level}, ${Date.now() - startedAt} ms`, + ); try { await this.alerts.evaluateAfterCheck( tenantId,