From 0daee2fc1e6726fcf88f3f4fb409507478faa634 Mon Sep 17 00:00:00 2001 From: Artem Niehrieiev Date: Tue, 8 Sep 2026 12:18:26 +0000 Subject: [PATCH] feat: add logging to SaaS company gateway and related use cases for better error tracking --- ...company-white-label-properties.use.case.ts | 10 +- .../get-full-user-company-info.use.case.ts | 7 +- .../use-cases/get-user-company.use.case.ts | 7 +- .../base-saas-gateway.service.ts | 55 ++++++++- .../saas-company-gateway.service.ts | 25 ++++- .../saas-usual-register-user.use.case.ts | 8 +- .../unit-tests/saas-company-gateway.test.ts | 106 ++++++++++++++++++ 7 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 backend/test/ava-tests/unit-tests/saas-company-gateway.test.ts diff --git a/backend/src/entities/company-info/use-cases/find-company-white-label-properties.use.case.ts b/backend/src/entities/company-info/use-cases/find-company-white-label-properties.use.case.ts index 506600ff2..d742e1c71 100644 --- a/backend/src/entities/company-info/use-cases/find-company-white-label-properties.use.case.ts +++ b/backend/src/entities/company-info/use-cases/find-company-white-label-properties.use.case.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; @@ -14,6 +14,8 @@ export class FindCompanyWhiteLabelPropertiesUseCase extends AbstractUseCase implements IGetCompanyWhiteLabelProperties { + private readonly logger = new Logger(FindCompanyWhiteLabelPropertiesUseCase.name); + constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, @@ -25,6 +27,7 @@ export class FindCompanyWhiteLabelPropertiesUseCase protected async implementation(companyId: string): Promise { const company = await this._dbContext.companyInfoRepository.findCompanyWithWhiteLabelProperties(companyId); if (!company) { + this.logger.warn(`White-label lookup: company ${companyId} does not exist in the core database`); throw new NotFoundException(Messages.COMPANY_NOT_FOUND); } @@ -32,6 +35,11 @@ export class FindCompanyWhiteLabelPropertiesUseCase if (isSaaS()) { const companyInfoFromSaas = await this.saasCompanyGatewayService.getCompanyInfo(companyId); if (!companyInfoFromSaas) { + // The company IS in the core (the guard just matched the caller to it) — the saas + // side is what came back empty; the gateway logged the HTTP status right before this. + this.logger.warn( + `White-label lookup: company ${companyId} exists in the core but the SaaS lookup returned no company data; responding 404 COMPANY_NOT_FOUND`, + ); throw new NotFoundException(Messages.COMPANY_NOT_FOUND); } companySubscriptionLevel = companyInfoFromSaas.subscriptionLevel ?? null; diff --git a/backend/src/entities/company-info/use-cases/get-full-user-company-info.use.case.ts b/backend/src/entities/company-info/use-cases/get-full-user-company-info.use.case.ts index be009ae8c..5f5d8383c 100644 --- a/backend/src/entities/company-info/use-cases/get-full-user-company-info.use.case.ts +++ b/backend/src/entities/company-info/use-cases/get-full-user-company-info.use.case.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Inject, Injectable, Scope } from '@nestjs/common'; +import { HttpException, HttpStatus, Inject, Injectable, Logger, Scope } from '@nestjs/common'; import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; @@ -19,6 +19,8 @@ export class GetUserCompanyFullInfoUseCase extends AbstractUseCase implements IGetUserFullCompanyInfo { + private readonly logger = new Logger(GetUserCompanyFullInfoUseCase.name); + constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, @@ -77,6 +79,9 @@ export class GetUserCompanyFullInfoUseCase if (isSaaS()) { foundUserCompanySaasInfo = await this.saasCompanyGatewayService.getCompanyInfo(foundFullUserCoreCompanyInfo.id); if (!foundUserCompanySaasInfo) { + this.logger.warn( + `Company ${foundFullUserCoreCompanyInfo.id} (user ${userId}) exists in the core but the SaaS lookup returned no company data; responding 404 COMPANY_NOT_FOUND`, + ); throw new HttpException( { message: Messages.COMPANY_NOT_FOUND, diff --git a/backend/src/entities/company-info/use-cases/get-user-company.use.case.ts b/backend/src/entities/company-info/use-cases/get-user-company.use.case.ts index fd589e52f..0d559d81f 100644 --- a/backend/src/entities/company-info/use-cases/get-user-company.use.case.ts +++ b/backend/src/entities/company-info/use-cases/get-user-company.use.case.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Inject, Injectable, Logger } from '@nestjs/common'; import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; @@ -11,6 +11,8 @@ import { IGetUserCompany } from './company-info-use-cases.interface.js'; @Injectable() export class GetUserCompanyUseCase extends AbstractUseCase implements IGetUserCompany { + private readonly logger = new Logger(GetUserCompanyUseCase.name); + constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, @@ -36,6 +38,9 @@ export class GetUserCompanyUseCase extends AbstractUseCase= 400) { + this.reportFailedRequest(method, patch, res.status, responseBody); + } return { status: res.status, - body: await this.bodyToJSON(res), + body: responseBody, }; } catch (e) { + // A thrown fetch (DNS, refused connection, TLS) means SAAS_URL itself is unreachable. + this.logger.error(`SaaS request ${method} ${patch} to ${this.baseSaaSUrl} threw: ${getErrorMessage(e)}`); Sentry.captureException(e); throw e; } } + // Callers turn a failed call into their own error (often a plain 404), so the upstream + // status and reason must be visible somewhere — here, once, for every core→saas call + // (401 = MICROSERVICE_JWT_SECRET mismatch, 404 = row missing on the saas this core is + // pointed at, 5xx = saas-side failure). Sentry groups by method + route shape + status, so a + // broken edge is one issue with an event per call rather than one issue per id in the URL. + private reportFailedRequest( + method: SaaSRequestMethod, + patch: string, + status: number, + body: Record, + ): void { + const message = `SaaS request ${method} ${patch} failed: HTTP ${status}${describeSaasErrorBody(body)}`; + this.logger.warn(message); + const route = normalizeSaasPath(patch); + Sentry.withScope((scope) => { + scope.setLevel(status >= 500 ? 'error' : 'warning'); + scope.setTag('saas_method', method); + scope.setTag('saas_route', route); + scope.setTag('saas_status', String(status)); + scope.setFingerprint(['saas-request-failed', method, route, String(status)]); + Sentry.captureMessage(message); + }); + } + private async bodyToJSON(res: Response): Promise> { if (!res.body) { return {}; @@ -56,3 +88,22 @@ export class BaseSaasGatewayService { } } } + +// "— " for the log line when the saas error body carries one; empty otherwise. +export function describeSaasErrorBody(body: Record): string { + const message = body?.message; + if (typeof message === 'string' && message.length) { + return ` — ${message.slice(0, 300)}`; + } + if (Array.isArray(message) && message.length) { + return ` — ${message.map(String).join(', ').slice(0, 300)}`; + } + return ''; +} + +const UUID_SEGMENT = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; + +// Route shape for tags/fingerprints: ids in the path replaced with ":id", query string dropped. +export function normalizeSaasPath(patch: string): string { + return patch.split('?')[0].replace(UUID_SEGMENT, ':id'); +} diff --git a/backend/src/microservices/gateways/saas-gateway.ts/saas-company-gateway.service.ts b/backend/src/microservices/gateways/saas-gateway.ts/saas-company-gateway.service.ts index 1d6a91595..f0da1f00b 100644 --- a/backend/src/microservices/gateways/saas-gateway.ts/saas-company-gateway.service.ts +++ b/backend/src/microservices/gateways/saas-gateway.ts/saas-company-gateway.service.ts @@ -1,14 +1,18 @@ import { Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/node'; import { ExternalServiceException } from '../../../exceptions/custom-exceptions/external-service-exception.js'; import { Messages } from '../../../exceptions/text/messages.js'; import { isSaaS } from '../../../helpers/app/is-saas.js'; import { isObjectEmpty } from '../../../helpers/is-object-empty.js'; import { SuccessResponse } from '../../saas-microservice/data-structures/common-responce.ds.js'; -import { BaseSaasGatewayService } from './base-saas-gateway.service.js'; +import { BaseSaasGatewayService, describeSaasErrorBody } from './base-saas-gateway.service.js'; import { FoundSassCompanyInfoDS } from './data-structures/found-saas-company-info.ds.js'; @Injectable() export class SaasCompanyGatewayService extends BaseSaasGatewayService { + // Returns null both when the saas has no such company AND when the call failed for any + // other reason (401/5xx/unexpected body) — every caller reports that null as + // COMPANY_NOT_FOUND, so the real reason is logged here before it is lost. public async getCompanyInfo(companyId: string): Promise { const result = await this.sendRequestToSaaS(`/webhook/company/${companyId}/`, 'GET', null); if (!result) { @@ -17,6 +21,25 @@ export class SaasCompanyGatewayService extends BaseSaasGatewayService { if (this.isDataFoundSassCompanyInfoDS(result.body)) { return result.body; } + if (result.status > 299) { + // The HTTP failure itself was already logged and sent to Sentry by the base gateway; + // this line only ties it to the user-facing consequence. + this.logger.warn( + `SaaS company lookup for company ${companyId} returned HTTP ${result.status}${describeSaasErrorBody(result.body)}; callers will report COMPANY_NOT_FOUND`, + ); + return null; + } + // A 2xx that is not a company is a contract break between the two services — nothing + // upstream reports it, so it is captured here. + const message = `SaaS company lookup for company ${companyId} returned HTTP ${result.status} with an unexpected body (keys: ${Object.keys(result.body ?? {}).join(', ') || 'none'}); callers will report COMPANY_NOT_FOUND`; + this.logger.warn(message); + Sentry.withScope((scope) => { + scope.setLevel('warning'); + scope.setTag('saas_lookup', 'company'); + scope.setTag('company_id', companyId); + scope.setFingerprint(['saas-company-lookup-unexpected-body']); + Sentry.captureMessage(message); + }); return null; } diff --git a/backend/src/microservices/saas-microservice/use-cases/saas-usual-register-user.use.case.ts b/backend/src/microservices/saas-microservice/use-cases/saas-usual-register-user.use.case.ts index 8c5fd3147..8017c890b 100644 --- a/backend/src/microservices/saas-microservice/use-cases/saas-usual-register-user.use.case.ts +++ b/backend/src/microservices/saas-microservice/use-cases/saas-usual-register-user.use.case.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Inject, Injectable, Logger } from '@nestjs/common'; import AbstractUseCase from '../../../common/abstract-use.case.js'; import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; import { BaseType } from '../../../common/data-injection.tokens.js'; @@ -21,6 +21,8 @@ export class SaasUsualRegisterUseCase extends AbstractUseCase implements ISaasRegisterUser { + private readonly logger = new Logger(SaasUsualRegisterUseCase.name); + constructor( @Inject(BaseType.GLOBAL_DB_CONTEXT) protected _dbContext: IGlobalDatabaseContext, @@ -58,11 +60,15 @@ export class SaasUsualRegisterUseCase const createdTestConnections = await this.demoDataService.createDemoDataForUser(savedUser.id); + // The company id is minted by the saas (its row already exists there); the core mirrors it + // under the same id. Logged so a later "company not found" can be matched to this moment. if (userCompany) { userCompany.users.push(savedUser); await this._dbContext.companyInfoRepository.save(userCompany); + this.logger.log(`SaaS registration: user ${savedUser.id} joined existing core company ${companyId}`); } else { await this.registerEmptyCompany(savedUser, createdTestConnections, companyId, companyName); + this.logger.log(`SaaS registration: user ${savedUser.id} registered, core company ${companyId} created`); } const { rawToken } = await this._dbContext.emailVerificationRepository.createOrUpdateEmailVerification(savedUser); diff --git a/backend/test/ava-tests/unit-tests/saas-company-gateway.test.ts b/backend/test/ava-tests/unit-tests/saas-company-gateway.test.ts new file mode 100644 index 000000000..98afe9d27 --- /dev/null +++ b/backend/test/ava-tests/unit-tests/saas-company-gateway.test.ts @@ -0,0 +1,106 @@ +import { Logger, type LoggerService } from '@nestjs/common'; +import test from 'ava'; + +// The gateway reads IS_SAAS / MICROSERVICE_JWT_SECRET / SAAS_URL through +// appConfig — set them before the module (and its appConfig import) loads. +process.env.IS_SAAS = '1'; +process.env.MICROSERVICE_JWT_SECRET = 'unit-test-secret'; +process.env.SAAS_URL = 'http://saas.unit.test'; + +const { SaasCompanyGatewayService } = await import( + '../../../src/microservices/gateways/saas-gateway.ts/saas-company-gateway.service.js' +); +const { normalizeSaasPath } = await import( + '../../../src/microservices/gateways/saas-gateway.ts/base-saas-gateway.service.js' +); + +const COMPANY_ID = 'b3363e0b-0101-4bc8-86cd-02516d407b62'; + +// Every reason a company lookup comes back empty ends up as a user-facing +// "Company not found" in the callers, so the gateway must leave the real reason +// (upstream status + message) in the log. These tests pin those log lines. +const captured: { level: string; message: string }[] = []; +const capturingLogger: LoggerService = { + log: (message: unknown) => captured.push({ level: 'log', message: String(message) }), + warn: (message: unknown) => captured.push({ level: 'warn', message: String(message) }), + error: (message: unknown) => captured.push({ level: 'error', message: String(message) }), + debug: () => {}, + verbose: () => {}, +}; +Logger.overrideLogger(capturingLogger); + +const realFetch = globalThis.fetch; + +function stubFetch(impl: (url: string) => Promise): void { + globalThis.fetch = impl as unknown as typeof fetch; +} + +test.afterEach.always(() => { + globalThis.fetch = realFetch; + captured.length = 0; +}); + +// global fetch + shared log capture are mutated per test -> serial only +test.serial('2xx with company data -> returns the body, nothing logged', async (t) => { + const seenUrls: string[] = []; + stubFetch(async (url) => { + seenUrls.push(url); + return new Response( + JSON.stringify({ id: COMPANY_ID, createdAt: '2026-09-08T11:00:00.000Z', updatedAt: '2026-09-08T11:00:00.000Z' }), + { status: 200 }, + ); + }); + const gateway = new SaasCompanyGatewayService(); + const info = await gateway.getCompanyInfo(COMPANY_ID); + t.is(info?.id, COMPANY_ID); + t.deepEqual(seenUrls, [`http://saas.unit.test/webhook/company/${COMPANY_ID}/`]); + t.deepEqual(captured, []); +}); + +test.serial('401 from the saas (secret mismatch) -> null, and the status + saas message are logged', async (t) => { + stubFetch(async () => new Response(JSON.stringify({ message: 'Invalid token' }), { status: 401 })); + const gateway = new SaasCompanyGatewayService(); + const info = await gateway.getCompanyInfo(COMPANY_ID); + t.is(info, null); + const warnings = captured.filter((entry) => entry.level === 'warn').map((entry) => entry.message); + t.true( + warnings.some((message) => message.includes('HTTP 401') && message.includes('Invalid token')), + `expected the upstream status and message in the log, got: ${JSON.stringify(warnings)}`, + ); + t.true(warnings.some((message) => message.includes(COMPANY_ID) && message.includes('COMPANY_NOT_FOUND'))); +}); + +test.serial('404 from the saas (row missing there) -> null, logged with the company id and HTTP 404', async (t) => { + stubFetch( + async () => + new Response(JSON.stringify({ message: 'Company not found. Please contact our support team' }), { status: 404 }), + ); + const gateway = new SaasCompanyGatewayService(); + t.is(await gateway.getCompanyInfo(COMPANY_ID), null); + const warnings = captured.filter((entry) => entry.level === 'warn').map((entry) => entry.message); + t.true(warnings.some((message) => message.includes(`company ${COMPANY_ID}`) && message.includes('HTTP 404'))); +}); + +test.serial('2xx without the expected fields -> null, logged as an unexpected body', async (t) => { + stubFetch(async () => new Response(JSON.stringify({ success: true }), { status: 200 })); + const gateway = new SaasCompanyGatewayService(); + t.is(await gateway.getCompanyInfo(COMPANY_ID), null); + const warnings = captured.filter((entry) => entry.level === 'warn').map((entry) => entry.message); + t.true(warnings.some((message) => message.includes('unexpected body') && message.includes('success'))); +}); + +test.serial('fetch throwing (SAAS_URL unreachable) -> rethrows, and the target base URL is logged', async (t) => { + stubFetch(async () => { + throw new TypeError('fetch failed'); + }); + const gateway = new SaasCompanyGatewayService(); + await t.throwsAsync(gateway.getCompanyInfo(COMPANY_ID), { message: 'fetch failed' }); + const errors = captured.filter((entry) => entry.level === 'error').map((entry) => entry.message); + t.true(errors.some((message) => message.includes('http://saas.unit.test') && message.includes('fetch failed'))); +}); + +test('normalizeSaasPath replaces ids and drops the query so Sentry groups by route shape', (t) => { + t.is(normalizeSaasPath(`/webhook/company/${COMPANY_ID}/`), '/webhook/company/:id/'); + t.is(normalizeSaasPath(`/webhook/company/${COMPANY_ID}/domain/?x=1`), '/webhook/company/:id/domain/'); + t.is(normalizeSaasPath('/webhook/company/domain/app.example.com/'), '/webhook/company/domain/app.example.com/'); +});