From 2212f6da41ebbec184ee194b020058d1ed34a65e Mon Sep 17 00:00:00 2001 From: "s.hajizadeh" Date: Sun, 14 Jun 2026 16:42:52 +0330 Subject: [PATCH] moallem all endpoints working --- .local.env | 55 +++++ package-lock.json | 48 ++++ package.json | 1 + src/common/helpers/http-client.helper.ts | 222 ++++++++++++++++++ src/common/helpers/request-logger.helper.ts | 29 ++- src/common/helpers/sayah-response.helper.ts | 70 ++++++ .../helpers/soap-civil-registration.helper.ts | 92 ++++++++ src/inquiry/dto/person-inquiry-data.dto.ts | 56 ++++- src/inquiry/inquiry.service.ts | 121 +++------- src/providers/base/base-provider.abstract.ts | 35 ++- .../implementations/amitis.provider.ts | 67 ++++-- .../implementations/hamta.provider.ts | 35 ++- .../implementations/moallem.provider.ts | 101 +++++--- .../implementations/parsian.provider.ts | 53 +++-- .../implementations/tejaratnou.provider.ts | 7 +- .../shared/legacy-api.provider.abstract.ts | 31 +-- .../strategy/provider-orchestrator.service.ts | 33 ++- src/telemetry.ts | 11 +- 18 files changed, 833 insertions(+), 234 deletions(-) create mode 100644 .local.env create mode 100644 src/common/helpers/http-client.helper.ts create mode 100644 src/common/helpers/sayah-response.helper.ts create mode 100644 src/common/helpers/soap-civil-registration.helper.ts diff --git a/.local.env b/.local.env new file mode 100644 index 0000000..c3057ec --- /dev/null +++ b/.local.env @@ -0,0 +1,55 @@ +# Application +PORT=8085 +NODE_ENV=development + +# MongoDB +MONGODB_URI=mongodb://localhost:27017/inquiry-gateway + +# Authentication +API_KEY=your-secure-api-key-here +JWT_SECRET=change-me-use-long-random-string +JWT_REFRESH_SECRET=change-me-refresh-secret +JWT_ACCESS_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d +JWT_ENABLED=true +BCRYPT_SALT_ROUNDS=12 + +# Rate limiting +THROTTLE_TTL=60 +THROTTLE_LIMIT=100 + +# Provider routing (per inquiry type) +PERSON_DEFAULT_PROVIDER=TEJARATNOU +PERSON_FALLBACK_ENABLED=false +PERSON_FALLBACK_PROVIDERS= +CAR_PLATE_DEFAULT_PROVIDER=HAMTA +CAR_PLATE_FALLBACK_ENABLED=false +CAR_PLATE_FALLBACK_PROVIDERS= + +# Hamta provider +HAMTA_BASE_URL=https://api.hamta.example.com +HAMTA_USERNAME= +HAMTA_PASSWORD= +HAMTA_SECRET_KEY= +HAMTA_TIMEOUT=10000 +HAMTA_ENABLED=true +HAMTA_MAX_RETRIES=2 + +# Moallem provider (identical API structure to Hamta) +MOALLEM_BASE_URL=https://api.moallem.example.com +MOALLEM_USERNAME= +MOALLEM_PASSWORD= +MOALLEM_SECRET_KEY= +MOALLEM_TIMEOUT=10000 +MOALLEM_ENABLED=true +MOALLEM_MAX_RETRIES=2 + +# TejaratNou provider +TEJARATNOU_BASE_URL=https://accounts.tejaratnoins.ir +TEJARATNOU_INQUIRY_BASE_URL=https://gateway.tejaratnoins.ir +TEJARATNOU_CLIENT_ID=api-gateway +TEJARATNOU_CLIENT_SECRET=hkld@ork123T +TEJARATNOU_USERNAME=thirdparty-silcogroup +TEJARATNOU_PASSWORD=FDHG87sdf787l764iuo +TEJARATNOU_TIMEOUT=15000 +TEJARATNOU_ENABLED=true diff --git a/package-lock.json b/package-lock.json index 6d2528e..957c966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socks-proxy-agent": "^7.0.0", "uuid": "^11.0.3" }, "devDependencies": { @@ -7847,6 +7848,15 @@ "node": ">=12.0.0" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -10927,6 +10937,44 @@ "node": ">=8" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://repo.hmirror.ir/artifactory/api/npm/mirror-npm/source-map/-/source-map-0.7.4.tgz", diff --git a/package.json b/package.json index a59e665..847dd1c 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socks-proxy-agent": "^7.0.0", "uuid": "^11.0.3" }, "devDependencies": { diff --git a/src/common/helpers/http-client.helper.ts b/src/common/helpers/http-client.helper.ts new file mode 100644 index 0000000..5258906 --- /dev/null +++ b/src/common/helpers/http-client.helper.ts @@ -0,0 +1,222 @@ +import axios, { + AxiosError, + AxiosInstance, + AxiosRequestConfig, + CreateAxiosDefaults, + InternalAxiosRequestConfig, +} from 'axios'; +import { Logger } from '@nestjs/common'; +import type { Agent as HttpAgent } from 'http'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { SocksProxyAgent } = require('socks-proxy-agent'); + +const outboundHttpLogger = new Logger('OutboundHTTP'); + +let cachedSocksAgents: { httpAgent: HttpAgent; httpsAgent: HttpAgent } | undefined; +let outboundHttpDebugInstalled = false; + +function getOutboundProxyUrl(): string | undefined { + const value = process.env.OUTBOUND_PROXY?.trim(); + return value || undefined; +} + +export function isOutboundHttpDebugEnabled(): boolean { + return process.env.OUTBOUND_HTTP_DEBUG === 'true'; +} + +function isSocksProxy(proxyUrl: string): boolean { + return /^socks/i.test(proxyUrl); +} + +function getSocksAgents(proxyUrl: string): { httpAgent: HttpAgent; httpsAgent: HttpAgent } { + if (!cachedSocksAgents) { + const agent = new SocksProxyAgent(proxyUrl); + cachedSocksAgents = { httpAgent: agent, httpsAgent: agent }; + } + return cachedSocksAgents; +} + +function truncate(value: string, max = 800): string { + return value.length <= max ? value : `${value.slice(0, max)}...(truncated)`; +} + +function sanitizeForLog(data: unknown): string { + if (data === undefined || data === null) { + return String(data); + } + + if (typeof data === 'string') { + return truncate( + data + .replace(/(<(?:\w+:)?Password[^>]*>)[^<]*(<\/(?:\w+:)?Password>)/gi, '$1***$2') + .replace(/password=[^&]*/gi, 'password=***') + .replace(/("password"\s*:\s*")[^"]*"/gi, '$1***"'), + ); + } + + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) { + return truncate(data.toString('utf8').replace(/password=[^&\r\n]*/gi, 'password=***')); + } + + try { + const serialized = JSON.stringify(data, (key, value) => { + if (/password|secret|authorization|token|refresh/i.test(key)) { + return '***'; + } + return value; + }); + return truncate(serialized); + } catch { + return truncate(String(data)); + } +} + +function resolveRequestUrl(config: InternalAxiosRequestConfig): string { + const url = config.url ?? ''; + if (/^https?:\/\//i.test(url)) { + return url; + } + + const base = config.baseURL ?? ''; + if (!base) { + return url; + } + + return `${base.replace(/\/$/, '')}/${url.replace(/^\//, '')}`; +} + +function logOutboundRequest(config: InternalAxiosRequestConfig): void { + const method = (config.method ?? 'GET').toUpperCase(); + const url = resolveRequestUrl(config); + outboundHttpLogger.log(`→ ${method} ${url}`); + + if (config.params) { + outboundHttpLogger.log(` query=${sanitizeForLog(config.params)}`); + } + if (config.data !== undefined) { + outboundHttpLogger.log(` body=${sanitizeForLog(config.data)}`); + } + if (config.headers?.SOAPAction) { + outboundHttpLogger.log(` SOAPAction=${String(config.headers.SOAPAction)}`); + } +} + +function logOutboundResponse(response: { + config: InternalAxiosRequestConfig; + status: number; + data: unknown; +}): void { + const start = (response.config as InternalAxiosRequestConfig & { _outboundStart?: number }) + ._outboundStart; + const durationMs = start ? Date.now() - start : undefined; + const method = (response.config.method ?? 'GET').toUpperCase(); + const url = resolveRequestUrl(response.config); + + outboundHttpLogger.log( + `← ${response.status} ${method} ${url}${durationMs !== undefined ? ` (${durationMs}ms)` : ''}`, + ); + outboundHttpLogger.log(` response=${sanitizeForLog(response.data)}`); +} + +function logOutboundError(error: AxiosError): void { + const config = error.config; + if (!config) { + outboundHttpLogger.error(`← FAILED | ${error.message}`); + return; + } + + const start = (config as InternalAxiosRequestConfig & { _outboundStart?: number })._outboundStart; + const durationMs = start ? Date.now() - start : undefined; + const method = (config.method ?? 'GET').toUpperCase(); + const url = resolveRequestUrl(config); + + outboundHttpLogger.error( + `← FAILED ${method} ${url}${durationMs !== undefined ? ` (${durationMs}ms)` : ''} | status=${error.response?.status ?? 'N/A'} | ${error.message}`, + ); + if (error.response?.data !== undefined) { + outboundHttpLogger.error(` response=${sanitizeForLog(error.response.data)}`); + } +} + +export function installOutboundHttpDebugInterceptors(): void { + if (!isOutboundHttpDebugEnabled() || outboundHttpDebugInstalled) { + return; + } + + outboundHttpDebugInstalled = true; + outboundHttpLogger.log('Outbound HTTP debug logging enabled (OUTBOUND_HTTP_DEBUG=true)'); + + axios.interceptors.request.use((config) => { + (config as InternalAxiosRequestConfig & { _outboundStart?: number })._outboundStart = + Date.now(); + logOutboundRequest(config); + return config; + }); + + axios.interceptors.response.use( + (response) => { + logOutboundResponse(response); + return response; + }, + (error: AxiosError) => { + logOutboundError(error); + return Promise.reject(error); + }, + ); +} + +installOutboundHttpDebugInterceptors(); + +/** + * Axios defaults for outbound provider calls. + * Set OUTBOUND_PROXY to route traffic through an SSH tunnel (e.g. Termius SOCKS on 6565). + */ +export function getOutboundAxiosDefaults(): AxiosRequestConfig { + const proxyUrl = getOutboundProxyUrl(); + if (!proxyUrl) { + return {}; + } + + if (isSocksProxy(proxyUrl)) { + const agents = getSocksAgents(proxyUrl); + return { + httpAgent: agents.httpAgent, + httpsAgent: agents.httpsAgent, + proxy: false, + }; + } + + const parsed = new URL(proxyUrl); + const defaultPort = parsed.protocol === 'https:' ? 443 : 80; + + return { + proxy: { + protocol: parsed.protocol.replace(':', ''), + host: parsed.hostname, + port: Number(parsed.port || defaultPort), + }, + }; +} + +export function mergeOutboundAxiosConfig(config: AxiosRequestConfig = {}): AxiosRequestConfig { + const outbound = getOutboundAxiosDefaults(); + + return { + ...outbound, + ...config, + httpAgent: config.httpAgent ?? outbound.httpAgent, + httpsAgent: config.httpsAgent ?? outbound.httpsAgent, + }; +} + +export function createOutboundAxiosInstance(config: CreateAxiosDefaults = {}): AxiosInstance { + const outbound = getOutboundAxiosDefaults(); + + return axios.create({ + ...config, + httpAgent: config.httpAgent ?? outbound.httpAgent, + httpsAgent: config.httpsAgent ?? outbound.httpsAgent, + proxy: config.proxy ?? outbound.proxy, + }); +} diff --git a/src/common/helpers/request-logger.helper.ts b/src/common/helpers/request-logger.helper.ts index 04c9c03..1914db9 100644 --- a/src/common/helpers/request-logger.helper.ts +++ b/src/common/helpers/request-logger.helper.ts @@ -1,4 +1,5 @@ import { Logger } from '@nestjs/common'; +import { NormalizedErrorDto } from '../dto/normalized-error.dto'; export interface RequestLogContext { requestId: string; @@ -29,10 +30,12 @@ export class RequestLogger { } logFailure(ctx: RequestLogContext, message: string, error?: unknown): void { - this.logger.error( - this.format({ ...ctx, success: false }, message), - error instanceof Error ? error.stack : undefined, - ); + const errorDetail = formatErrorDetail(error); + const line = errorDetail + ? `${this.format({ ...ctx, success: false }, message)} | error=${errorDetail}` + : this.format({ ...ctx, success: false }, message); + + this.logger.error(line, error instanceof Error ? error.stack : undefined); } private format(ctx: RequestLogContext, message: string): string { @@ -49,3 +52,21 @@ export class RequestLogger { return parts.join(' | '); } } + +function formatErrorDetail(error: unknown): string | undefined { + if (!(error instanceof Error)) { + return error !== undefined ? String(error) : undefined; + } + + const normalized = (error as Error & { normalizedError?: NormalizedErrorDto }).normalizedError; + if (normalized?.providerMessage) { + const code = normalized.providerCode ? ` (${normalized.providerCode})` : ''; + return `${normalized.providerMessage}${code}`; + } + + if (normalized?.message && normalized.message !== error.message) { + return normalized.message; + } + + return error.message; +} diff --git a/src/common/helpers/sayah-response.helper.ts b/src/common/helpers/sayah-response.helper.ts new file mode 100644 index 0000000..fdf2a59 --- /dev/null +++ b/src/common/helpers/sayah-response.helper.ts @@ -0,0 +1,70 @@ +export interface SayahApiResponse { + ReturnValue?: boolean; + HasError?: boolean; + IsSucceed?: boolean; + isSucceed?: boolean; + Errors?: Record | Array<{ Code?: string; Message?: string }> | null; + errors?: Record | Array<{ Code?: string; Message?: string }> | null; + Result?: { + ErrorMessage?: string | null; + [key: string]: unknown; + }; +} + +export function formatSayahErrors( + errors?: Record | Array<{ Code?: string; Message?: string }> | null, +): string { + if (!errors) { + return 'Request failed'; + } + + if (Array.isArray(errors)) { + if (errors.length === 0) { + return 'Request failed'; + } + return errors.map((error) => `${error.Code}: ${error.Message}`).join(', '); + } + + const entries = Object.entries(errors); + if (entries.length === 0) { + return 'Request failed'; + } + + return entries.map(([code, field]) => `${field} (code: ${code})`).join(', '); +} + +export function getSayahProviderError( + body: SayahApiResponse, +): { message: string; code: string } | null { + if ('ReturnValue' in body || 'HasError' in body) { + if (body.HasError) { + return { + message: formatSayahErrors(body.Errors), + code: 'SAYAH_ERROR', + }; + } + + if (body.ReturnValue === false) { + return { + message: formatSayahErrors(body.Errors), + code: 'SHEBA_MISMATCH', + }; + } + + return null; + } + + const isSucceed = body.IsSucceed ?? body.isSucceed; + if (isSucceed === false) { + const errors = body.Errors ?? body.errors; + return { + message: + (Array.isArray(errors) || (errors && Object.keys(errors).length > 0) + ? formatSayahErrors(errors) + : body.Result?.ErrorMessage) ?? 'Request failed', + code: 'API_ERROR', + }; + } + + return null; +} diff --git a/src/common/helpers/soap-civil-registration.helper.ts b/src/common/helpers/soap-civil-registration.helper.ts new file mode 100644 index 0000000..898cffd --- /dev/null +++ b/src/common/helpers/soap-civil-registration.helper.ts @@ -0,0 +1,92 @@ +function decodeXml(value: string): string { + return value + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/>/g, '>') + .replace(/</g, '<') + .replace(/&/g, '&'); +} + +function normalizeSoapTextValue(value: string): string { + const nestedStrings = [ + ...value.matchAll(/<(?:[\w]+:)?string[^>]*>([\s\S]*?)<\/(?:[\w]+:)?string>/gi), + ] + .map((match) => decodeXml(match[1].trim())) + .filter(Boolean); + + if (nestedStrings.length > 0) { + return nestedStrings.join(', '); + } + + return decodeXml(value.trim()); +} + +export function parseCiiEstelamResult(soapXml: string): Record { + const civilBlockMatch = + soapXml.match( + /<(?:[\w]+:)?CiiEstelamResult[^>]*>([\s\S]*?)<\/(?:[\w]+:)?CiiEstelamResult>/i, + ) ?? + soapXml.match( + /<(?:[\w]+:)?SubmitInqDteStsWithPstCodResult[^>]*>([\s\S]*?)<\/(?:[\w]+:)?SubmitInqDteStsWithPstCodResult>/i, + ); + + if (!civilBlockMatch) { + return {}; + } + + const fields: Record = {}; + const tagRegex = /<(?:[\w]+:)?(\w+)(?:[^>]*)>([\s\S]*?)<\/(?:[\w]+:)?\1>/gi; + let match: RegExpExecArray | null; + + while ((match = tagRegex.exec(civilBlockMatch[1])) !== null) { + fields[match[1]] = normalizeSoapTextValue(match[2]); + } + + const errorNamsMatch = soapXml.match( + /<(?:[\w]+:)?ErrorNams(?![^>]*i:nil="true")[^>]*>([\s\S]*?)<\/(?:[\w]+:)?ErrorNams>/i, + ); + if (errorNamsMatch) { + const errorNams = normalizeSoapTextValue(errorNamsMatch[1]); + if (errorNams) { + fields.ErrorNams = errorNams; + } + } + + return fields; +} + +export function getCivilRegistrationProviderError( + fields: Record, +): { message: string; code: string } | null { + const message = fields.Message?.trim(); + const exceptionMessage = fields.ExceptionMessage?.trim(); + const errorNams = fields.ErrorNams?.trim(); + const nin = fields.Nin?.trim(); + const hasIdentity = Boolean(fields.Name?.trim() || fields.Family?.trim()); + + if (exceptionMessage) { + return { message: exceptionMessage, code: 'PROVIDER_ERROR' }; + } + + if (errorNams) { + return { message: errorNams, code: 'PROVIDER_ERROR' }; + } + + if (message && message.includes('err.')) { + return { message, code: 'RECORD_NOT_FOUND' }; + } + + if (!nin || nin === '0' || !hasIdentity) { + return { + message: message || 'Record not found', + code: 'RECORD_NOT_FOUND', + }; + } + + return null; +} + +export function buildCivilRegistrationFullName(fields: Record): string | undefined { + const fullName = [fields.Name, fields.Family].filter(Boolean).join(' ').trim(); + return fullName || undefined; +} diff --git a/src/inquiry/dto/person-inquiry-data.dto.ts b/src/inquiry/dto/person-inquiry-data.dto.ts index ecd9e57..4c22d71 100644 --- a/src/inquiry/dto/person-inquiry-data.dto.ts +++ b/src/inquiry/dto/person-inquiry-data.dto.ts @@ -7,6 +7,60 @@ export class PersonInquiryDataDto { @ApiProperty({ example: '1370-05-15' }) birthDate!: string; - @ApiPropertyOptional({ example: 'علی محمدی' }) + @ApiPropertyOptional({ example: 'سهیل حاجی زاده' }) fullName?: string; + + @ApiPropertyOptional({ example: '4311402422' }) + Nin?: string; + + @ApiPropertyOptional({ example: 'سهيل' }) + Name?: string; + + @ApiPropertyOptional({ example: 'حاجي زاده' }) + Family?: string; + + @ApiPropertyOptional({ example: 'كوروش' }) + FatherName?: string; + + @ApiPropertyOptional({ example: 'د41' }) + Shenasnameseri?: string; + + @ApiPropertyOptional({ example: '709633' }) + Shenasnameserial?: string; + + @ApiPropertyOptional({ example: '0' }) + ShenasnameNo?: string; + + @ApiPropertyOptional({ example: '13781124' }) + BirthDate?: string; + + @ApiPropertyOptional({ example: '1' }) + Gender?: string; + + @ApiPropertyOptional({ example: '0' }) + OfficeCode?: string; + + @ApiPropertyOptional({ example: '0' }) + BookNo?: string; + + @ApiPropertyOptional({ example: '0' }) + BookRow?: string; + + @ApiPropertyOptional({ example: '0' }) + DeathStatus?: string; + + @ApiPropertyOptional() + Message?: string; + + @ApiPropertyOptional() + DeathDate?: string; + + @ApiPropertyOptional() + ExceptionMessage?: string; + + @ApiPropertyOptional({ example: '1349689554' }) + Zipcode?: string; + + @ApiPropertyOptional() + ZipcodeDesc?: string; } diff --git a/src/inquiry/inquiry.service.ts b/src/inquiry/inquiry.service.ts index a731f74..b24bb25 100644 --- a/src/inquiry/inquiry.service.ts +++ b/src/inquiry/inquiry.service.ts @@ -23,76 +23,19 @@ export class InquiryService { private readonly inquiryLogService: InquiryLogService, ) {} - /** - * Person inquiry — unified business flow with provider fallback and audit logging. - */ async inquirePerson( dto: PersonInquiryRequestDto, requestId: string, ): Promise> { - const trackingCode = generateTrackingCode(); - const payload: PersonInquiryPayload = { - nationalCode: dto.nationalCode, - birthDate: dto.birthDate, - dateHasPostfix: (dto as PersonInquiryRequestDto & { dateHasPostfix?: number }).dateHasPostfix, - }; - - const start = Date.now(); - - try { - const result = await this.executeInquiry( - InquiryType.PERSON, - payload, - requestId, - trackingCode, - ); - - const response = this.buildSuccessResponse( - result.data, - result.provider, - trackingCode, - result.duration, - 'Person inquiry completed successfully', - ); - - await this.persistLog({ - inquiryType: InquiryType.PERSON, - provider: result.provider, - requestPayload: payload as unknown as Record, - responsePayload: response.data as unknown as Record, - status: InquiryStatus.SUCCESS, - duration: result.duration, - requestId, - trackingCode, - }); - - return response; - } catch (error) { - const duration = Date.now() - start; - const normalized = this.extractError(error); - - const response: BaseInquiryResponseDto = { - success: false, - provider: 'GATEWAY', - trackingCode, - message: normalized.message, - error: normalized, - duration, - }; - - await this.persistLog({ - inquiryType: InquiryType.PERSON, - provider: ProviderName.HAMTA, - requestPayload: payload as unknown as Record, - status: InquiryStatus.FAILURE, - duration, - requestId, - trackingCode, - error: normalized as unknown as Record, - }).catch(() => undefined); - - return response; - } + return this.inquire( + InquiryType.PERSON, + { + nationalCode: dto.nationalCode, + birthDate: dto.birthDate, + dateHasPostfix: dto.dateHasPostfix, + }, + requestId, + ) as unknown as Promise>; } async inquire( @@ -111,7 +54,7 @@ export class InquiryService { trackingCode, ); - const response = this.buildGenericSuccessResponse( + const response = this.buildSuccessResponse( result.data, result.provider, trackingCode, @@ -172,27 +115,6 @@ export class InquiryService { } private buildSuccessResponse( - result: PersonInquiryResult, - provider: string, - trackingCode: string, - duration: number, - message: string, - ): BaseInquiryResponseDto { - return { - success: true, - provider, - trackingCode, - message, - duration, - data: { - nationalCode: result.nationalCode, - birthDate: result.birthDate, - fullName: result.fullName, - }, - }; - } - - private buildGenericSuccessResponse( result: unknown, provider: string, trackingCode: string, @@ -210,6 +132,20 @@ export class InquiryService { } private toResponseData(result: unknown): Record { + if (this.isPersonInquiryResult(result)) { + const raw = + result.raw && typeof result.raw === 'object' && !Array.isArray(result.raw) + ? (result.raw as Record) + : {}; + + return { + nationalCode: result.nationalCode, + birthDate: result.birthDate, + ...(result.fullName ? { fullName: result.fullName } : {}), + ...raw, + }; + } + if (result && typeof result === 'object' && 'raw' in result) { const raw = (result as { raw: unknown }).raw; return raw && typeof raw === 'object' ? (raw as Record) : { raw }; @@ -220,6 +156,15 @@ export class InquiryService { : { raw: result }; } + private isPersonInquiryResult(result: unknown): result is PersonInquiryResult { + return ( + typeof result === 'object' && + result !== null && + 'nationalCode' in result && + 'birthDate' in result + ); + } + private getInquiryLabel(inquiryType: InquiryType): string { return inquiryType.replace(/_INQUIRY$/, '').toLowerCase().replace(/_/g, ' '); } diff --git a/src/providers/base/base-provider.abstract.ts b/src/providers/base/base-provider.abstract.ts index 901c96c..b4aa443 100644 --- a/src/providers/base/base-provider.abstract.ts +++ b/src/providers/base/base-provider.abstract.ts @@ -1,4 +1,5 @@ import { Logger } from '@nestjs/common'; +import { AxiosError } from 'axios'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; import { NormalizedErrorDto } from '../../common/dto/normalized-error.dto'; @@ -131,19 +132,41 @@ export abstract class BaseProvider protected formatProviderError( providerMessage?: string, providerCode?: string, - fallbackMessage = 'Provider returned an error', + fallbackMessage = 'Provider request failed', ): Error { + const message = providerMessage?.trim() || fallbackMessage; + const code = providerCode?.trim() || 'PROVIDER_ERROR'; const normalized = this.normalizeError({ - code: 'PROVIDER_ERROR', - message: fallbackMessage, - providerMessage, - providerCode, + code, + message, + providerMessage: message, + providerCode: code, }); - const err = new Error(normalized.message); + const err = new Error(message); (err as Error & { normalizedError: NormalizedErrorDto }).normalizedError = normalized; return err; } + protected formatAxiosError(error: AxiosError): Error { + const data = error.response?.data; + if (typeof data === 'string') { + const faultMatch = data.match( + /<(?:\w+:)?faultstring[^>]*>([\s\S]*?)<\/(?:\w+:)?faultstring>/i, + ); + if (faultMatch?.[1]) { + return this.formatProviderError( + faultMatch[1].trim().replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'), + String(error.response?.status ?? 'NETWORK_ERROR'), + ); + } + } + + return this.formatProviderError( + error.message, + String(error.response?.status ?? 'NETWORK_ERROR'), + ); + } + protected abstract callProvider( inquiryType: InquiryType, payload: TRequest, diff --git a/src/providers/implementations/amitis.provider.ts b/src/providers/implementations/amitis.provider.ts index c2948c9..0a85bd1 100644 --- a/src/providers/implementations/amitis.provider.ts +++ b/src/providers/implementations/amitis.provider.ts @@ -1,6 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import axios, { AxiosError, AxiosInstance } from 'axios'; +import { AxiosError, AxiosInstance } from 'axios'; +import { + createOutboundAxiosInstance, + isOutboundHttpDebugEnabled, +} from '../../common/helpers/http-client.helper'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; import { AmitisAuthServiceConfig } from '../../config/configuration'; @@ -22,6 +26,8 @@ interface CentInsurTokenResponse { ExpiresIn?: number | string; expiresIn?: number | string; expires_in?: number | string; + IsSucceed?: boolean; + LoginStatus?: number; data?: CentInsurTokenResponse; } @@ -49,7 +55,7 @@ export class AmitisProvider { private readonly generalTokenService: GeneralTokenService, ) { this.config = configService.get('amitis')!; - this.httpClient = axios.create({ + this.httpClient = createOutboundAxiosInstance({ baseURL: this.config.baseUrl, timeout: this.config.timeout, }); @@ -102,25 +108,36 @@ export class AmitisProvider { username: string, password: string, ): Promise { - // Use FormData for multipart/form-data format - const FormData = require('form-data'); - const formData = new FormData(); - formData.append('username', username); - formData.append('password', password); + const loginBody = new URLSearchParams({ + username: username.toLowerCase(), + password, + }); try { + const loginUrl = `${this.config.baseUrl}${this.config.loginPath}`; + this.logger.log( + `AMITIS login → POST ${loginUrl} | provider=${providerName} | inquiry=${inquiryType} | username=${username.toLowerCase()}`, + ); + const response = await this.httpClient.post( this.config.loginPath, - formData, + loginBody.toString(), { headers: { - ...formData.getHeaders(), + 'Content-Type': 'application/x-www-form-urlencoded', }, }, ); + + if (isOutboundHttpDebugEnabled()) { + this.logger.log( + `AMITIS login ← ${response.status} | provider=${providerName} | inquiry=${inquiryType} | bodyKeys=${Object.keys(response.data ?? {}).join(',') || 'none'}`, + ); + } + const token = this.extractToken(response.data); - await this.saveToken(token, providerName, inquiryType, username, 'login'); + await this.saveToken(token, providerName, inquiryType, username, password, 'login'); return token.accessToken; } catch (error) { throw this.toAuthError(`AMITIS login failed for ${providerName}/${inquiryType}`, error); @@ -144,6 +161,7 @@ export class AmitisProvider { providerName, inquiryType, latestToken.username, + latestToken.clientSecret, 'refresh', ); return token.accessToken; @@ -160,15 +178,16 @@ export class AmitisProvider { providerName: ProviderName, inquiryType: InquiryType, username: string, + password: string, scope: string, ): Promise { await this.generalTokenService.create({ serviceProvider: this.getTokenKey(providerName, inquiryType), tokenType: token.tokenType, url: this.config.baseUrl, - clientId: '', - clientSecret: '', - username, + clientId: username.toLowerCase(), + clientSecret: password, + username: username.toLowerCase(), scope, accessToken: token.accessToken, refreshToken: token.refreshToken, @@ -182,11 +201,21 @@ export class AmitisProvider { fallbackRefreshToken?: string, ): AmitisAuthToken { const body = responseBody.data ?? responseBody; + + if (body.IsSucceed === false) { + throw new Error( + `AMITIS login rejected (LoginStatus=${body.LoginStatus ?? 'unknown'})`, + ); + } + const accessToken = body.Token ?? body.token ?? body.AccessToken ?? body.accessToken ?? body.access_token; - if (!accessToken) { - throw new Error('AMITIS auth response did not include an access token'); + if (!accessToken || !String(accessToken).trim()) { + const bodyPreview = JSON.stringify(responseBody).slice(0, 500); + throw new Error( + `AMITIS auth response did not include an access token | body=${bodyPreview}`, + ); } const expiresIn = Number(body.ExpiresIn ?? body.expiresIn ?? body.expires_in ?? 20 * 60); @@ -229,7 +258,13 @@ export class AmitisProvider { private toAuthError(message: string, error: unknown): Error { if (error instanceof AxiosError) { const status = error.response?.status ?? 'NETWORK_ERROR'; - return new Error(`${message}: ${status} ${error.message}`); + const body = + error.response?.data !== undefined + ? JSON.stringify(error.response.data).slice(0, 500) + : undefined; + return new Error( + body ? `${message}: ${status} ${error.message} | body=${body}` : `${message}: ${status} ${error.message}`, + ); } if (error instanceof Error) { diff --git a/src/providers/implementations/hamta.provider.ts b/src/providers/implementations/hamta.provider.ts index 9a46058..617cb76 100644 --- a/src/providers/implementations/hamta.provider.ts +++ b/src/providers/implementations/hamta.provider.ts @@ -1,6 +1,11 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios, { AxiosError } from 'axios'; +import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper'; +import { + getSayahProviderError, + SayahApiResponse, +} from '../../common/helpers/sayah-response.helper'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderEnvConfig } from '../../config/configuration'; @@ -107,11 +112,11 @@ export class HamtaProvider extends LegacyApiProvider { try { const response = await axios.get>( `${inquiryConfig.url}/AddressByPostcode`, - { + mergeOutboundAxiosConfig({ params: { PostalCode: postalCode }, headers: { Authorization: `Bearer ${token}` }, timeout: this.hamtaConfig.timeout, - }, + }), ); return { raw: response.data }; @@ -146,14 +151,14 @@ export class HamtaProvider extends LegacyApiProvider { inquiryConfig.username, inquiryConfig.password, ), - { + mergeOutboundAxiosConfig({ headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: '"http://tempuri.org/ISabtInq/SubmitInqDteStsWithPstCod"', }, timeout: this.hamtaConfig.timeout, responseType: 'text', - }, + }), ); const result = this.extractSoapValue(response.data, 'SubmitInqDteStsWithPstCodResult'); @@ -195,14 +200,14 @@ export class HamtaProvider extends LegacyApiProvider { inquiryConfig.username, inquiryConfig.password, ), - { + mergeOutboundAxiosConfig({ headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"', }, timeout: this.hamtaConfig.timeout, responseType: 'text', - }, + }), ); const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult'); @@ -241,11 +246,7 @@ export class HamtaProvider extends LegacyApiProvider { ); try { - const response = await axios.post<{ - IsSucceed?: boolean; - Errors?: Array<{ Code?: string; Message?: string }>; - Result?: unknown; - }>( + const response = await axios.post( inquiryConfig.url, { AccountOwnerType: accountOwnerType, @@ -253,20 +254,18 @@ export class HamtaProvider extends LegacyApiProvider { LegalId: legalId, ShebaId: shebaId, }, - { + mergeOutboundAxiosConfig({ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, timeout: this.hamtaConfig.timeout, - }, + }), ); - if (!response.data.IsSucceed && response.data.Errors) { - throw this.formatProviderError( - this.formatErrors(response.data.Errors), - 'SAYAH_HAS_ERROR', - ); + const providerError = getSayahProviderError(response.data); + if (providerError) { + throw this.formatProviderError(providerError.message, providerError.code); } return { raw: response.data }; diff --git a/src/providers/implementations/moallem.provider.ts b/src/providers/implementations/moallem.provider.ts index e7004ac..7045b2c 100644 --- a/src/providers/implementations/moallem.provider.ts +++ b/src/providers/implementations/moallem.provider.ts @@ -1,6 +1,16 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios, { AxiosError } from 'axios'; +import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper'; +import { + buildCivilRegistrationFullName, + getCivilRegistrationProviderError, + parseCiiEstelamResult, +} from '../../common/helpers/soap-civil-registration.helper'; +import { + getSayahProviderError, + SayahApiResponse, +} from '../../common/helpers/sayah-response.helper'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderEnvConfig } from '../../config/configuration'; @@ -10,6 +20,7 @@ import { LegacyApiProvider, LegacyInquiryPayload, LegacyInquiryResult, + PersonInquiryResult, } from '../shared/legacy-api.provider.abstract'; interface CivilRegistrationPayload extends LegacyInquiryPayload { @@ -107,11 +118,11 @@ export class MoallemProvider extends LegacyApiProvider { try { const response = await axios.get>( `${inquiryConfig.url}/AddressByPostcode`, - { + mergeOutboundAxiosConfig({ params: { PostalCode: postalCode }, headers: { Authorization: `Bearer ${token}` }, timeout: this.moallemConfig.timeout, - }, + }), ); return { raw: response.data }; @@ -128,7 +139,7 @@ export class MoallemProvider extends LegacyApiProvider { private async inquireCivilRegistration( payload: CivilRegistrationPayload, - ): Promise<{ raw: unknown }> { + ): Promise { const nationalCode = this.getRequiredString( payload.nationalCode ?? payload.NIN, 'nationalCode', @@ -141,37 +152,39 @@ export class MoallemProvider extends LegacyApiProvider { const response = await axios.post( inquiryConfig.url, this.buildCivilRegistrationEnvelope( + payload, nationalCode, birthDate, inquiryConfig.username, inquiryConfig.password, ), - { + mergeOutboundAxiosConfig({ headers: { 'Content-Type': 'text/xml; charset=utf-8', - SOAPAction: '"http://tempuri.org/ISabtV3/SabtInquery"', + SOAPAction: '"http://tempuri.org/ISabtInq/SubmitInqDteStsWithPstCod"', }, timeout: this.moallemConfig.timeout, responseType: 'text', - }, + }), ); - const result = this.extractSoapValue(response.data, 'SabtInqueryResult'); + const civilRegistration = parseCiiEstelamResult(response.data); + const providerError = getCivilRegistrationProviderError(civilRegistration); + if (providerError) { + throw this.formatProviderError(providerError.message, providerError.code); + } + + const fullName = buildCivilRegistrationFullName(civilRegistration); return { - raw: { - nationalCode, - birthDate, - result, - soap: response.data, - }, + nationalCode, + birthDate, + fullName: fullName || undefined, + raw: civilRegistration, }; } catch (error) { if (error instanceof AxiosError) { - throw this.formatProviderError( - error.message, - String(error.response?.status ?? 'NETWORK_ERROR'), - ); + throw this.formatAxiosError(error); } throw error; } @@ -195,14 +208,14 @@ export class MoallemProvider extends LegacyApiProvider { inquiryConfig.username, inquiryConfig.password, ), - { + mergeOutboundAxiosConfig({ headers: { 'Content-Type': 'text/xml; charset=utf-8', SOAPAction: '"http://tempuri.org/IShahkarInq/ShahkarInquery"', }, timeout: this.moallemConfig.timeout, responseType: 'text', - }, + }), ); const result = this.extractSoapValue(response.data, 'ShahkarInqueryResult'); @@ -241,11 +254,7 @@ export class MoallemProvider extends LegacyApiProvider { ); try { - const response = await axios.post<{ - IsSucceed?: boolean; - Errors?: Array<{ Code?: string; Message?: string }>; - Result?: unknown; - }>( + const response = await axios.post( inquiryConfig.url, { AccountOwnerType: accountOwnerType, @@ -253,20 +262,18 @@ export class MoallemProvider extends LegacyApiProvider { LegalId: legalId, ShebaId: shebaId, }, - { + mergeOutboundAxiosConfig({ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, timeout: this.moallemConfig.timeout, - }, + }), ); - if (!response.data.IsSucceed && response.data.Errors) { - throw this.formatProviderError( - this.formatErrors(response.data.Errors), - 'SAYAH_HAS_ERROR', - ); + const providerError = getSayahProviderError(response.data); + if (providerError) { + throw this.formatProviderError(providerError.message, providerError.code); } return { raw: response.data }; @@ -282,22 +289,38 @@ export class MoallemProvider extends LegacyApiProvider { } private buildCivilRegistrationEnvelope( + payload: CivilRegistrationPayload, nationalCode: string, birthDate: string, username: string, password: string, ): string { + const birthDateCompact = birthDate.replace(/-/g, ''); + const dateHasPostfix = payload.dateHasPostfix ?? 0; + return ` - - ${this.escapeXml(nationalCode)} - ${this.escapeXml(birthDate)} + + + ${this.escapeXml(nationalCode)} + 0 + 0 + ${this.escapeXml(birthDateCompact)} + ${dateHasPostfix} + 0 + 0 + 0 + 0 + 0 + 0 + 0 + ${this.escapeXml(username)} ${this.escapeXml(password)} - + `; } @@ -341,10 +364,10 @@ export class MoallemProvider extends LegacyApiProvider { private escapeXml(value: string): string { return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') .replace(/'/g, '''); } diff --git a/src/providers/implementations/parsian.provider.ts b/src/providers/implementations/parsian.provider.ts index 0dfad25..8a04d09 100644 --- a/src/providers/implementations/parsian.provider.ts +++ b/src/providers/implementations/parsian.provider.ts @@ -1,6 +1,11 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios, { AxiosError } from 'axios'; +import { mergeOutboundAxiosConfig } from '../../common/helpers/http-client.helper'; +import { + getSayahProviderError, + SayahApiResponse, +} from '../../common/helpers/sayah-response.helper'; import { InquiryType } from '../../common/enums/inquiry-type.enum'; import { ProviderName } from '../../common/enums/provider-name.enum'; import { ProviderExecutionContext } from '../../common/interfaces/inquiry-provider.interface'; @@ -172,16 +177,19 @@ export class ParsianProvider extends BaseProvider(inquiryConfig.url, { - params: { - nationalCode, - mobileNumber, - }, - headers: { - 'X-PACKAGE-API-KEY': inquiryConfig.apiKey, - }, - timeout: this.config.timeout, - }); + const response = await axios.get( + inquiryConfig.url, + mergeOutboundAxiosConfig({ + params: { + nationalCode, + mobileNumber, + }, + headers: { + 'X-PACKAGE-API-KEY': inquiryConfig.apiKey, + }, + timeout: this.config.timeout, + }), + ); const body = response.data; return { @@ -230,14 +238,14 @@ export class ParsianProvider extends BaseProvider( + const response = await axios.post( inquiryConfig.url, { AccountOwnerType: accountOwnerType, @@ -404,23 +412,18 @@ export class ParsianProvider extends BaseProvider('tejaratnou')!; - this.httpClient = axios.create({ + this.httpClient = createOutboundAxiosInstance({ baseURL: this.config.inquiryBaseUrl, timeout: this.config.timeout, headers: { @@ -227,13 +228,13 @@ export class TejaratNouProvider implements InquiryProvider `${field} (code: ${code})`).join(', ') - : 'Request failed'; - throw this.formatProviderError( - errorMessages, - 'SAYAH_ERROR', - ); + const providerError = getSayahProviderError(body); + if (providerError) { + throw this.formatProviderError(providerError.message, providerError.code); } return { raw: body }; } @@ -168,14 +164,11 @@ export abstract class LegacyApiProvider extends BaseProvider< const data = error.response?.data as LegacyProviderApiResponse | undefined; // Check for Sayah/Sheba format error - if (data && ('ReturnValue' in data || 'HasError' in data) && data.HasError) { - const errorMessages = data.Errors - ? Object.entries(data.Errors).map(([code, field]) => `${field} (code: ${code})`).join(', ') - : error.message; - throw this.formatProviderError( - errorMessages, - String(error.response?.status ?? 'SAYAH_ERROR'), - ); + if (data && ('ReturnValue' in data || 'HasError' in data)) { + const providerError = getSayahProviderError(data); + if (providerError) { + throw this.formatProviderError(providerError.message, providerError.code); + } } // Check for CentInsur format error diff --git a/src/providers/strategy/provider-orchestrator.service.ts b/src/providers/strategy/provider-orchestrator.service.ts index 72d5a2b..8104066 100644 --- a/src/providers/strategy/provider-orchestrator.service.ts +++ b/src/providers/strategy/provider-orchestrator.service.ts @@ -55,27 +55,34 @@ export class ProviderOrchestratorService { } catch (error) { const normalized = this.extractError(error); errors.push(normalized); + const errorSummary = normalized.providerMessage ?? normalized.message; const hasNextProvider = providers.indexOf(provider) < providers.length - 1; this.logger.warn( hasNextProvider - ? `Provider ${provider.name} failed for ${inquiryType}, trying next fallback` - : `Provider ${provider.name} failed for ${inquiryType}, no fallback available`, + ? `Provider ${provider.name} failed for ${inquiryType}, trying next fallback | ${errorSummary}` + : `Provider ${provider.name} failed for ${inquiryType}, no fallback available | ${errorSummary}`, ); - this.logger.debug( - `Attempt duration: ${Date.now() - attemptStart}ms | error: ${normalized.message}`, + this.logger.warn( + `Attempt duration: ${Date.now() - attemptStart}ms | providerCode=${normalized.providerCode ?? normalized.code} | providerMessage=${normalized.providerMessage ?? normalized.message}`, ); } } - throw new InquiryException('All providers failed', { - code: providers.length === 1 ? 'PROVIDER_SERVICE_NOT_AVAILABLE' : 'ALL_PROVIDERS_FAILED', - message: - providers.length === 1 - ? `${providers[0].name} service is not available` - : errors.map((e) => e.message).join('; '), - providerMessage: errors[0]?.providerMessage, - providerCode: errors[0]?.providerCode, - }); + const lastError = errors[errors.length - 1]!; + + if (providers.length === 1) { + throw new InquiryException(lastError.message, lastError); + } + + throw new InquiryException( + errors.map((error) => error.message).join('; '), + { + code: 'ALL_PROVIDERS_FAILED', + message: errors.map((error) => error.message).join('; '), + providerMessage: lastError.providerMessage ?? lastError.message, + providerCode: lastError.providerCode ?? lastError.code, + }, + ); } private extractError(error: unknown): NormalizedErrorDto { diff --git a/src/telemetry.ts b/src/telemetry.ts index 08d0fbf..2a4708d 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -106,8 +106,15 @@ console.debug = (...args: unknown[]) => { originalConsole.debug(...args); }; -export function shutdownTelemetry(): Promise { - return sdk.shutdown(); +export async function shutdownTelemetry(): Promise { + try { + await sdk.shutdown(); + } catch (error) { + originalConsole.warn( + 'OpenTelemetry shutdown failed:', + error instanceof Error ? error.message : String(error), + ); + } } export function recordHttpRequestStart(attributes: Record): void {