diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts index 22f2b73..8f8d114 100644 --- a/backend/src/app.controller.ts +++ b/backend/src/app.controller.ts @@ -2,6 +2,23 @@ import { Controller, Get } from '@nestjs/common'; @Controller() export class AppController { + /** + * GET /api — bare root of the API. + * Handles requests that omit the trailing slash so nginx's `location /api/` + * block (which requires a trailing slash) doesn't fall through to the Vite + * frontend proxy. Also gives New Relic and health checkers a real 200 rather + * than a 404 that would register as a phantom transaction. + */ + @Get() + getRoot() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + service: 'hoa-financial-platform', + }; + } + + /** GET /api/health — explicit named health endpoint for uptime monitors */ @Get('health') getHealth() { return { diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3e8c30b..1f0d4a1 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { TenantMiddleware } from './database/tenant.middleware'; import { WriteAccessGuard } from './common/guards/write-access.guard'; import { CapabilityGuard } from './common/guards/capability.guard'; import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor'; +import { NewRelicTransactionInterceptor } from './common/interceptors/newrelic-transaction.interceptor'; import { AuthModule } from './modules/auth/auth.module'; import { OrganizationsModule } from './modules/organizations/organizations.module'; import { UsersModule } from './modules/users/users.module'; @@ -109,6 +110,10 @@ import { ScheduleModule } from '@nestjs/schedule'; provide: APP_INTERCEPTOR, useClass: NoCacheInterceptor, }, + { + provide: APP_INTERCEPTOR, + useClass: NewRelicTransactionInterceptor, + }, ], }) export class AppModule implements NestModule { diff --git a/backend/src/common/interceptors/newrelic-transaction.interceptor.ts b/backend/src/common/interceptors/newrelic-transaction.interceptor.ts new file mode 100644 index 0000000..71adeaa --- /dev/null +++ b/backend/src/common/interceptors/newrelic-transaction.interceptor.ts @@ -0,0 +1,46 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { Request } from 'express'; + +/** + * Sets a meaningful New Relic transaction name for every HTTP request. + * + * Without this, NestJS's setGlobalPrefix('api') causes the New Relic Express + * instrumentation to group ALL requests under the generic bucket + * "Expressjs/GET/api$" (the compiled regex for the global prefix router), + * making per-endpoint APM data completely blind. + * + * This interceptor runs after NestJS routing (so req.route is populated with + * the matched pattern, e.g. "/api/accounts/:id") and calls newrelic.setTransactionName() + * to override the auto-detected name with "METHOD /route/pattern". + * + * Gracefully no-ops when: + * - NEW_RELIC_ENABLED is not 'true' (dev / CI) + * - newrelic package is not installed + * - The NR agent fails to load for any reason + */ + +let newrelic: any; +try { + if (process.env.NEW_RELIC_ENABLED === 'true') { + // eslint-disable-next-line @typescript-eslint/no-require-imports + newrelic = require('newrelic'); + } +} catch { + // Package not installed in this environment — skip instrumentation silently +} + +@Injectable() +export class NewRelicTransactionInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (newrelic) { + const req = context.switchToHttp().getRequest(); + // req.route.path is the Express matched route template (e.g. "/api/accounts/:id"). + // Falls back to req.path (the actual URL) for unmatched requests so even + // 404s get a useful name like "GET /api/unknown-path" instead of "Expressjs/GET/api$". + const route: string = (req.route as any)?.path ?? req.path; + newrelic.setTransactionName(`${req.method} ${route}`); + } + return next.handle(); + } +} diff --git a/nginx/default.conf b/nginx/default.conf index 6f921b3..019417f 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -10,6 +10,22 @@ server { listen 80; server_name localhost; + # Exact match for bare /api (no trailing slash). + # nginx's `location /api/` below requires a trailing slash, so a request for + # GET /api would fall through to the Vite proxy, which then forwards it to + # the backend — arriving as an unmatched path that New Relic registers as + # the phantom "Expressjs/GET/api$" transaction bucket. + # This exact-match block catches it first and proxies it directly to the + # backend, where AppController's @Get() handler returns a clean 200. + location = /api { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # API requests -> NestJS backend location /api/ { proxy_pass http://backend;