diff --git a/apps/payments/api/.env b/apps/payments/api/.env index 9057e047d10..0653c0b2232 100644 --- a/apps/payments/api/.env +++ b/apps/payments/api/.env @@ -101,3 +101,9 @@ METERING_CONFIG__CLOUD_TASKS__THRESHOLD__TASK_URL=http://host.docker.internal:30 METERING_CONFIG__CLOUD_TASKS__THRESHOLD__QUEUE_NAME=metering-threshold-checks METERING_CONFIG__CLOUD_TASKS__THRESHOLD__BUCKET_SIZE_MS=5000 METERING_CONFIG__CLOUD_TASKS__THRESHOLD__SCHEDULE_DELAY_MS=10000 + +SENTRY_CONFIG__DSN= +SENTRY_CONFIG__ENV=local +SENTRY_CONFIG__SERVER_NAME=payments-api +SENTRY_CONFIG__SAMPLE_RATE=1 +SENTRY_CONFIG__TRACES_SAMPLE_RATE=0 diff --git a/apps/payments/api/src/app/app.module.ts b/apps/payments/api/src/app/app.module.ts index e22fe27394c..22043a64de5 100644 --- a/apps/payments/api/src/app/app.module.ts +++ b/apps/payments/api/src/app/app.module.ts @@ -1,4 +1,12 @@ -import { Logger, Module } from '@nestjs/common'; +import { + Logger, + MiddlewareConsumer, + Module, + NestModule, + RequestMethod, +} from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup'; import { TypedConfigModule, dotenvLoader } from 'nest-typed-config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -58,6 +66,7 @@ import { PaypalCustomerManager, } from '@fxa/payments/paypal'; import { CurrencyManager } from '@fxa/payments/currency'; +import { StatsDRouteMiddleware } from '@fxa/shared/metrics/statsd'; import { AccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account'; import { AccountManager } from '@fxa/shared/account/account'; import { CartManager } from '@fxa/payments/cart'; @@ -76,6 +85,7 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat @Module({ imports: [ + SentryModule.forRoot(), AuthModule, TypedConfigModule.forRoot({ schema: RootConfig, @@ -99,6 +109,10 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat MeteringCloudTasksController, ], providers: [ + { + provide: APP_FILTER, + useClass: SentryGlobalFilter, + }, Logger, AccountCustomerManager, AccountDatabaseNestFactory, @@ -155,4 +169,15 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat UsageService, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(StatsDRouteMiddleware) + .exclude( + { path: '__heartbeat__', method: RequestMethod.GET }, + { path: '__lbheartbeat__', method: RequestMethod.GET }, + { path: '__version__', method: RequestMethod.GET } + ) + .forRoutes('*'); + } +} diff --git a/apps/payments/api/src/config/index.ts b/apps/payments/api/src/config/index.ts index 3bdf0edba9a..12063b3518b 100644 --- a/apps/payments/api/src/config/index.ts +++ b/apps/payments/api/src/config/index.ts @@ -13,6 +13,7 @@ import { FxaWebhookConfig, StripeEventConfig } from '@fxa/payments/webhooks'; import { StatsDConfig } from '@fxa/shared/metrics/statsd'; import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config'; import { FxaOAuthConfig } from '@fxa/payments/auth'; +import { SentryConfig } from './sentry.config'; export class RootConfig { @Type(() => MySQLConfig) @@ -50,6 +51,11 @@ export class RootConfig { @IsDefined() public readonly statsDConfig!: Partial; + @Type(() => SentryConfig) + @ValidateNested() + @IsDefined() + public readonly sentryConfig!: Partial; + @Type(() => StrapiClientConfig) @ValidateNested() @IsDefined() diff --git a/apps/payments/api/src/config/sentry.config.ts b/apps/payments/api/src/config/sentry.config.ts new file mode 100644 index 00000000000..aa44ce5dd21 --- /dev/null +++ b/apps/payments/api/src/config/sentry.config.ts @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Type } from 'class-transformer'; +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +export class SentryConfig { + @IsOptional() + @IsString() + dsn?: string; + + @IsString() + env!: string; + + @IsString() + serverName!: string; + + @Type(() => Number) + @IsNumber() + sampleRate!: number; + + @Type(() => Number) + @IsNumber() + tracesSampleRate!: number; +} diff --git a/apps/payments/api/src/main.ts b/apps/payments/api/src/main.ts index bbd83d257fc..f6f26068b53 100644 --- a/apps/payments/api/src/main.ts +++ b/apps/payments/api/src/main.ts @@ -3,6 +3,9 @@ * This is only a minimal backend to get started. */ +// Must be the first import so Sentry patches Nest before it boots. +import './monitoring'; + import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app/app.module'; diff --git a/apps/payments/api/src/monitoring.ts b/apps/payments/api/src/monitoring.ts new file mode 100644 index 00000000000..82a01d23e57 --- /dev/null +++ b/apps/payments/api/src/monitoring.ts @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { existsSync } from 'fs'; +import { join } from 'path'; + +import * as dotenv from 'dotenv'; +import { initSentry } from '@fxa/shared/sentry-nest'; + +// Sentry must initialize before `@nestjs/core` is imported so the +// SDK's async-context patches attach to every Nest handler. Nest's +// TypedConfigModule has not run yet, so pull configuration straight +// from the same `.env` files the module would load. +for (const file of ['.env.local', '.env']) { + const path = join(process.cwd(), file); + if (existsSync(path)) { + dotenv.config({ path, override: false }); + } +} + +const parseNumber = (raw: string | undefined, fallback: number) => { + if (raw === undefined || raw === '') return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +initSentry( + { + release: process.env.APP_VERSION, + sentry: { + dsn: process.env.SENTRY_CONFIG__DSN, + env: process.env.SENTRY_CONFIG__ENV, + serverName: process.env.SENTRY_CONFIG__SERVER_NAME, + sampleRate: parseNumber(process.env.SENTRY_CONFIG__SAMPLE_RATE, 0), + tracesSampleRate: parseNumber( + process.env.SENTRY_CONFIG__TRACES_SAMPLE_RATE, + 0 + ), + }, + }, + console +); diff --git a/libs/shared/metrics/statsd/src/index.ts b/libs/shared/metrics/statsd/src/index.ts index 125298e3640..801bae9ae70 100644 --- a/libs/shared/metrics/statsd/src/index.ts +++ b/libs/shared/metrics/statsd/src/index.ts @@ -6,3 +6,4 @@ export * from './lib/statsd'; export * from './lib/statsd.config'; export * from './lib/statsd.provider'; export * from './lib/statsd.decorator'; +export * from './lib/statsd-route.middleware'; diff --git a/libs/shared/metrics/statsd/src/lib/statsd-route.middleware.spec.ts b/libs/shared/metrics/statsd/src/lib/statsd-route.middleware.spec.ts new file mode 100644 index 00000000000..15b0e2b9d46 --- /dev/null +++ b/libs/shared/metrics/statsd/src/lib/statsd-route.middleware.spec.ts @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EventEmitter } from 'events'; +import type { NextFunction, Request, Response } from 'express'; +import { StatsD } from 'hot-shots'; + +import { StatsDRouteMiddleware } from './statsd-route.middleware'; + +type FakeResponse = Response & { finish: () => void }; + +const buildStatsD = (): jest.Mocked> => ({ + increment: jest.fn(), + timing: jest.fn(), +}); + +const buildRequest = (overrides: Partial = {}): Request => + ({ + method: 'GET', + path: '/some/path', + ...overrides, + }) as unknown as Request; + +const buildResponse = (statusCode = 200): FakeResponse => { + const emitter = new EventEmitter(); + const res = Object.assign(emitter, { + statusCode, + finish: () => emitter.emit('finish'), + }); + return res as unknown as FakeResponse; +}; + +describe('StatsDRouteMiddleware', () => { + let statsd: ReturnType; + let middleware: StatsDRouteMiddleware; + let next: jest.MockedFunction; + + beforeEach(() => { + statsd = buildStatsD(); + middleware = new StatsDRouteMiddleware(statsd as unknown as StatsD); + next = jest.fn(); + }); + + it('calls next immediately and defers metric emission to the response finish event', () => { + const req = buildRequest({ route: { path: '/v1/things/:id' } } as Partial); + const res = buildResponse(200); + + middleware.use(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(statsd.increment).not.toHaveBeenCalled(); + expect(statsd.timing).not.toHaveBeenCalled(); + }); + + it('emits count and timing tagged with method/route/statusCode when the response finishes', () => { + const req = buildRequest({ + method: 'POST', + route: { path: '/v1/billing-and-subscriptions' }, + } as Partial); + const res = buildResponse(201); + + middleware.use(req, res, next); + res.finish(); + + const expectedTags = { + method: 'POST', + route: 'v1/billing-and-subscriptions', + statusCode: '201', + }; + expect(statsd.increment).toHaveBeenCalledTimes(1); + expect(statsd.increment).toHaveBeenCalledWith( + 'route.request.count', + expectedTags + ); + expect(statsd.timing).toHaveBeenCalledTimes(1); + expect(statsd.timing).toHaveBeenCalledWith( + 'route.request.duration', + expect.any(Number), + expectedTags + ); + }); + + it('emits with statusCode 401 when a guard rejects the request', () => { + const req = buildRequest({ + route: { path: '/v1/billing-and-subscriptions' }, + } as Partial); + const res = buildResponse(401); + + middleware.use(req, res, next); + res.finish(); + + expect(statsd.increment).toHaveBeenCalledWith( + 'route.request.count', + expect.objectContaining({ statusCode: '401' }) + ); + }); + + it('emits with statusCode 500 when the handler throws', () => { + const req = buildRequest({ + route: { path: '/v1/billing-and-subscriptions' }, + } as Partial); + const res = buildResponse(500); + + middleware.use(req, res, next); + res.finish(); + + expect(statsd.increment).toHaveBeenCalledWith( + 'route.request.count', + expect.objectContaining({ statusCode: '500' }) + ); + }); + + it('tags route as "root" when the matched path is just "/"', () => { + const req = buildRequest({ route: { path: '/' } } as Partial); + const res = buildResponse(200); + + middleware.use(req, res, next); + res.finish(); + + expect(statsd.increment).toHaveBeenCalledWith( + 'route.request.count', + expect.objectContaining({ route: 'root' }) + ); + }); + + it('tags route as "unmatched" when Express did not populate req.route', () => { + const req = buildRequest(); + const res = buildResponse(404); + + middleware.use(req, res, next); + res.finish(); + + expect(statsd.increment).toHaveBeenCalledWith( + 'route.request.count', + expect.objectContaining({ route: 'unmatched', statusCode: '404' }) + ); + }); +}); diff --git a/libs/shared/metrics/statsd/src/lib/statsd-route.middleware.ts b/libs/shared/metrics/statsd/src/lib/statsd-route.middleware.ts new file mode 100644 index 00000000000..41eebee1123 --- /dev/null +++ b/libs/shared/metrics/statsd/src/lib/statsd-route.middleware.ts @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Inject, Injectable, NestMiddleware } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; +import { StatsD } from 'hot-shots'; + +import { StatsDService } from './statsd.provider'; + +/** + * Express middleware that emits StatsD metrics for every HTTP response: + * - `route.request.count` : counter, incremented once per request + * - `route.request.duration` : timing (ms) from middleware entry to + * `res.on('finish')` (response fully flushed) + * + * Both metrics are tagged with `method`, `route`, and `statusCode`. + * `route` is `req.route.path` (the Express route pattern, e.g. + * `/subscriptions/:id`) when a handler matched, or `'unmatched'` for + * 404s and requests rejected before routing. + * + * Runs at the Express layer, before Nest's guard/interceptor pipeline, + * so it captures auth failures (401/403), pipe validation errors, + * handler exceptions, and successful responses in a single path. + */ +@Injectable() +export class StatsDRouteMiddleware implements NestMiddleware { + constructor(@Inject(StatsDService) private readonly statsd: StatsD) {} + + use(req: Request, res: Response, next: NextFunction) { + const start = performance.now(); + + res.on('finish', () => { + const elapsed = performance.now() - start; + const tags = { + method: req.method, + route: normalizeRoute(req.route?.path), + statusCode: String(res.statusCode), + }; + this.statsd.increment('route.request.count', tags); + this.statsd.timing('route.request.duration', elapsed, tags); + }); + + next(); + } +} + +/** + * Strips the leading `/` from an Express route pattern so downstream + * statsd receivers (Graphite/Prometheus/etc.) that sanitize `/` don't + * produce tag values like `-v1-billing-and-subscriptions`. Falls back + * to `unmatched` when no route was resolved (e.g., 404). + */ +export const normalizeRoute = (path: string | undefined): string => { + if (!path) return 'unmatched'; + return path.replace(/^\//, '') || 'root'; +};