feat(api): foundation canon JCS — Pino+CLS+RFC9457+health+ping (Frente A)

Estabelece a fundação operacional de apps/api conforme STACK.md v2.2 e
CODING-RULES.md v2.0, substituindo o hello-world scaffolded.

- tracing.ts primeiro import (PGD-OBS-001) — stub OTel ativável por env
- EnvSchema Zod 4 com fail-fast (superRefine guarda prod) + EnvModule global
- nestjs-pino com redact LGPD (*.cpf|*.cardNumber|*.password|auth|cookie)
- main.ts hardenizado: helmet, CORS por env, compression, versionamento URI
  /api/v1, graceful shutdown
- ProblemDetailsFilter global (RFC 9457 application/problem+json), Zod -> 422
- Health endpoints /api/v1/health/{live,ready} com memory.checkHeap(350MB)
  (ready skeleton documenta K=3 LRU pool conforme PGD-OBS-003)
- WorkspaceModule via ClsModule.forRootAsync — requestId+workspaceId no CLS,
  idGenerator alinhado com pino-http para mesmo UUID em header e body
- GET /api/v1/ping retornando workspaceId+requestId (alvo de smoke test e
  futuro healthcheck do docker compose)
- ZodValidationPipe (nestjs-zod) como APP_PIPE global
- tsconfig.app.json target ES2023 (alinhado ao base)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 17:50:42 +00:00
parent 3a42723c71
commit 055f9f98f0
21 changed files with 1148 additions and 80 deletions

View File

@@ -1,21 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let app: TestingModule;
beforeAll(async () => {
app = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
});
describe('getData', () => {
it('should return "Hello API"', () => {
const appController = app.get<AppController>(AppController);
expect(appController.getData()).toEqual({ message: 'Hello API' });
});
});
});

View File

@@ -1,12 +0,0 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getData() {
return this.appService.getData();
}
}

View File

@@ -1,10 +1,28 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { ZodValidationPipe } from 'nestjs-zod';
import { EnvModule } from './config/env.module';
import { LoggerModule } from './logger/logger.module';
import { WorkspaceModule } from './workspace/workspace.module';
import { HealthModule } from './health/health.module';
import { PingModule } from './ping/ping.module';
import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
imports: [],
controllers: [AppController],
providers: [AppService],
imports: [
// Ordem importa: Env primeiro (fail-fast), depois Logger, CLS, módulos de domínio.
EnvModule,
LoggerModule,
WorkspaceModule,
HealthModule,
PingModule,
],
providers: [
// Pipe global: nestjs-zod converte ZodSchema (via createZodDto) em validação automática.
// CODING-RULES §06: schema é o contrato; DTO é a classe que o expõe.
{ provide: APP_PIPE, useClass: ZodValidationPipe },
// Filter global: RFC 9457. Zod → 422.
{ provide: APP_FILTER, useClass: ProblemDetailsFilter },
],
})
export class AppModule {}

View File

@@ -1,20 +0,0 @@
import { Test } from '@nestjs/testing';
import { AppService } from './app.service';
describe('AppService', () => {
let service: AppService;
beforeAll(async () => {
const app = await Test.createTestingModule({
providers: [AppService],
}).compile();
service = app.get<AppService>(AppService);
});
describe('getData', () => {
it('should return "Hello API"', () => {
expect(service.getData()).toEqual({ message: 'Hello API' });
});
});
});

View File

@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getData(): { message: string } {
return { message: 'Hello API' };
}
}

View File

@@ -0,0 +1,21 @@
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Global, Module } from '@nestjs/common';
import type { Env } from './env.schema';
import { validateEnv } from './env.schema';
// Tipagem do ConfigService<Env, true> garante chaves sem `string | undefined` no caller.
export type AppConfigService = ConfigService<Env, true>;
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
// CODING-RULES §08: validate com Zod — fail-fast.
validate: (raw) => validateEnv(raw),
}),
],
exports: [ConfigModule],
})
export class EnvModule {}

View File

@@ -0,0 +1,98 @@
import { z } from 'zod';
// CODING-RULES.md §08: validação Zod no boot (fail-fast). Sem default em prod.
// Em dev, defaults razoáveis facilitam o setup; em prod o Vault Agent injeta tudo.
const NodeEnv = z.enum(['development', 'test', 'staging', 'production']);
export const EnvSchema = z
.object({
NODE_ENV: NodeEnv.default('development'),
// API
API_PORT: z.coerce.number().int().positive().default(3000),
API_HOST: z.string().min(1).default('0.0.0.0'),
API_GLOBAL_PREFIX: z.string().min(1).default('api'),
API_VERSION: z.string().regex(/^v\d+$/).default('v1'),
// CORS — origens permitidas (Web em dev: http://localhost:4200)
CORS_ORIGINS: z
.string()
.default('http://localhost:4200')
.transform((s) =>
s
.split(',')
.map((o) => o.trim())
.filter(Boolean),
),
// Master-login (DEV stub — IdP real virá na próxima sessão)
MASTER_LOGIN_URL: z.url().default('http://localhost:3000/auth/dev'),
MASTER_LOGIN_JWT_SECRET: z.string().min(32).default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'),
JWT_ACCESS_EXPIRATION: z.coerce.number().int().positive().default(900),
JWT_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000),
// Multi-tenancy — workspace de dev (até master-login real entrar)
DEFAULT_WORKSPACE_ID: z.string().min(1).default('dev-workspace'),
// Postgres (Prisma virá depois)
DATABASE_URL: z.string().optional(),
MIGRATION_DATABASE_URL: z.string().optional(),
// Valkey / BullMQ (entrarão com filas)
REDIS_URL: z.url().default('redis://localhost:6379'),
// MinIO (entrará com uploads)
S3_ENDPOINT: z.url().optional(),
S3_REGION: z.string().optional(),
S3_ACCESS_KEY: z.string().optional(),
S3_SECRET_KEY: z.string().optional(),
S3_BUCKET: z.string().optional(),
// SMTP (Mailpit dev / Resend prod)
SMTP_HOST: z.string().default('localhost'),
SMTP_PORT: z.coerce.number().int().positive().default(1025),
SMTP_FROM: z.email().default('noreply@sar.dev'),
// Telemetry
OTEL_SERVICE_NAME: z.string().default('sar-api'),
OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(),
OTEL_TRACES_SAMPLER: z.string().default('parentbased_traceidratio'),
OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0),
SENTRY_DSN: z.string().optional(),
// Feature flags
GROWTHBOOK_API_HOST: z.string().optional(),
GROWTHBOOK_CLIENT_KEY: z.string().optional(),
// Logger
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
})
.superRefine((env, ctx) => {
if (env.NODE_ENV === 'production') {
if (env.MASTER_LOGIN_JWT_SECRET.startsWith('dev_jwt_secret')) {
ctx.addIssue({
code: 'custom',
path: ['MASTER_LOGIN_JWT_SECRET'],
message: 'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).',
});
}
if (!env.DATABASE_URL) {
ctx.addIssue({ code: 'custom', path: ['DATABASE_URL'], message: 'obrigatório em produção' });
}
}
});
export type Env = z.infer<typeof EnvSchema>;
export function validateEnv(raw: Record<string, unknown>): Env {
const result = EnvSchema.safeParse(raw);
if (!result.success) {
// Fail-fast: agrupa erros legíveis antes do crash.
const issues = result.error.issues
.map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
.join('\n');
throw new Error(`[env] validação falhou — corrija .env:\n${issues}`);
}
return result.data;
}

View File

@@ -0,0 +1,170 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { ZodError } from 'zod';
// RFC 9457 Problem Details for HTTP APIs.
// CODING-RULES §05: Zod sempre 422 (nunca 400). 403 vira 404 onde o cliente
// não pode saber da existência (regra aplicada por guards de domínio — aqui
// só repassamos o status que o caller escolheu).
const PROBLEM_CONTENT_TYPE = 'application/problem+json';
const BASE_TYPE_URI = 'https://docs.sar.jcs.com.br/errors';
interface ProblemDetails {
type: string;
title: string;
status: number;
detail?: string;
instance: string;
requestId?: string;
errors?: ReadonlyArray<{ path: string; message: string; code?: string }>;
[key: string]: unknown;
}
@Catch()
export class ProblemDetailsFilter implements ExceptionFilter {
private readonly logger = new Logger(ProblemDetailsFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const req = ctx.getRequest<Request>();
const res = ctx.getResponse<Response>();
const problem = this.toProblem(exception, req);
if (problem.status >= 500) {
this.logger.error(
{ err: exception, requestId: problem.requestId, path: problem.instance },
problem.title,
);
}
res.setHeader('Content-Type', PROBLEM_CONTENT_TYPE);
res.status(problem.status).json(problem);
}
private toProblem(exception: unknown, req: Request): ProblemDetails {
const requestId = this.extractRequestId(req);
const instance = req.originalUrl ?? req.url ?? '/';
// Zod direto (validação manual ou rethrow de pipe sem nestjs-zod).
if (exception instanceof ZodError) {
return this.zodProblem(exception, instance, requestId);
}
if (exception instanceof HttpException) {
return this.httpProblem(exception, instance, requestId);
}
return {
type: `${BASE_TYPE_URI}/internal-server-error`,
title: 'Internal Server Error',
status: HttpStatus.INTERNAL_SERVER_ERROR,
detail: 'Erro interno. Reporte ao suporte com o requestId.',
instance,
requestId,
};
}
private zodProblem(err: ZodError, instance: string, requestId?: string): ProblemDetails {
return {
type: `${BASE_TYPE_URI}/validation`,
title: 'Unprocessable Entity',
status: HttpStatus.UNPROCESSABLE_ENTITY,
detail: 'Validação dos dados de entrada falhou.',
instance,
requestId,
errors: err.issues.map((i) => ({
path: i.path.join('.'),
message: i.message,
code: i.code,
})),
};
}
private httpProblem(err: HttpException, instance: string, requestId?: string): ProblemDetails {
const status = err.getStatus();
const response = err.getResponse();
// nestjs-zod lança ZodValidationException (HttpException 400/422) com payload já estruturado.
if (typeof response === 'object' && response !== null) {
const r = response as Record<string, unknown>;
// nestjs-zod 5.x: { statusCode, message, errors: ZodIssue[] }
if (Array.isArray(r['errors']) && r['errors'].length > 0) {
const issues = r['errors'] as Array<{ path?: unknown; message?: unknown; code?: unknown }>;
return {
type: `${BASE_TYPE_URI}/validation`,
title: 'Unprocessable Entity',
status: HttpStatus.UNPROCESSABLE_ENTITY,
detail: typeof r['message'] === 'string' ? (r['message'] as string) : 'Validação falhou.',
instance,
requestId,
errors: issues.map((i) => ({
path: Array.isArray(i.path) ? i.path.join('.') : String(i.path ?? ''),
message: String(i.message ?? ''),
code: typeof i.code === 'string' ? i.code : undefined,
})),
};
}
const title = this.titleFor(status);
return {
type: `${BASE_TYPE_URI}/${this.slug(title)}`,
title,
status,
detail: typeof r['message'] === 'string' ? (r['message'] as string) : title,
instance,
requestId,
};
}
const title = this.titleFor(status);
return {
type: `${BASE_TYPE_URI}/${this.slug(title)}`,
title,
status,
detail: typeof response === 'string' ? response : title,
instance,
requestId,
};
}
private extractRequestId(req: Request): string | undefined {
const fromHeader = req.headers['x-request-id'];
if (typeof fromHeader === 'string') return fromHeader;
const reqId = (req as Request & { id?: unknown }).id;
return typeof reqId === 'string' ? reqId : undefined;
}
private titleFor(status: number): string {
switch (status) {
case 400:
return 'Bad Request';
case 401:
return 'Unauthorized';
case 403:
return 'Forbidden';
case 404:
return 'Not Found';
case 409:
return 'Conflict';
case 422:
return 'Unprocessable Entity';
case 429:
return 'Too Many Requests';
default:
return status >= 500 ? 'Internal Server Error' : 'Error';
}
}
private slug(title: string): string {
return title.toLowerCase().replace(/\s+/g, '-');
}
}

View File

@@ -0,0 +1,42 @@
import { Controller, Get } from '@nestjs/common';
import {
HealthCheck,
HealthCheckResult,
HealthCheckService,
MemoryHealthIndicator,
} from '@nestjs/terminus';
// CODING-RULES §20 (PGD-OBS-003):
// /health/live → liveness só com memory.checkHeap(350MB).
// /health/ready → readiness pinga master-login + amostra LRU (K=3) dos pools
// quentes do WorkspacePrismaPool + Valkey + BullMQ.
// NUNCA percorrer todos os workspaces (O(N) → false negative).
//
// Hoje o "ready" só checa heap, idêntico ao live. Quando master-login,
// WorkspacePrismaPool, Valkey e BullMQ entrarem, cada um adiciona seu indicator
// aqui — sem nunca virar O(N) sobre workspaces.
@Controller({ path: 'health' })
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly memory: MemoryHealthIndicator,
) {}
@Get('live')
@HealthCheck()
live(): Promise<HealthCheckResult> {
return this.health.check([() => this.memory.checkHeap('heap', 350 * 1024 * 1024)]);
}
@Get('ready')
@HealthCheck()
ready(): Promise<HealthCheckResult> {
// Skeleton: por enquanto idêntico ao live. Próximas frentes:
// - MasterLoginHealthIndicator (obrigatório)
// - WorkspacePoolLruHealthIndicator (K=3 amostra)
// - ValkeyHealthIndicator
// - BullMQHealthIndicator
return this.health.check([() => this.memory.checkHeap('heap', 350 * 1024 * 1024)]);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
@Module({
imports: [TerminusModule],
controllers: [HealthController],
})
export class HealthModule {}

View File

@@ -0,0 +1,81 @@
import { Module } from '@nestjs/common';
import { LoggerModule as PinoLoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { ConfigService } from '@nestjs/config';
import type { Env } from '../config/env.schema';
// CODING-RULES §09 (PGD-OBS-002): redact LGPD agressivo.
// Sempre cobrir: *.cpf, *.cardNumber, *.password, req.headers.authorization, req.headers.cookie.
const REDACT_PATHS = [
'*.cpf',
'*.cardNumber',
'*.password',
'*.passwordHash',
'*.token',
'*.secret',
'req.headers.authorization',
'req.headers.cookie',
'req.headers["set-cookie"]',
'res.headers["set-cookie"]',
];
@Module({
imports: [
PinoLoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<Env, true>) => {
const isProd = config.get('NODE_ENV', { infer: true }) === 'production';
const level = config.get('LOG_LEVEL', { infer: true });
const serviceName = config.get('OTEL_SERVICE_NAME', { infer: true });
return {
pinoHttp: {
level,
// pretty em dev; JSON estruturado em prod (Grafana LGTM consumirá).
transport: isProd
? undefined
: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:HH:MM:ss.l',
ignore: 'pid,hostname,req,res,responseTime',
messageFormat: '{msg} {req.method} {req.url} {res.statusCode} ({responseTime}ms)',
},
},
redact: {
paths: REDACT_PATHS,
censor: '[REDACTED]',
},
// Correlation ID: respeita header upstream ou gera UUID v4.
genReqId: (req: IncomingMessage, res: ServerResponse) => {
const headerVal =
req.headers['x-request-id'] ??
req.headers['x-correlation-id'] ??
req.headers['traceparent'];
const id = typeof headerVal === 'string' && headerVal.length > 0 ? headerVal : randomUUID();
res.setHeader('x-request-id', id);
return id;
},
customProps: () => ({ service: serviceName }),
// Reduz ruído em health checks.
customLogLevel: (_req, res, err) => {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
autoLogging: {
ignore: (req) => {
const url = req.url ?? '';
return url.startsWith('/api/v1/health');
},
},
},
};
},
}),
],
exports: [PinoLoggerModule],
})
export class LoggerModule {}

View File

@@ -0,0 +1,36 @@
import { Controller, Get } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
// Endpoint de verificação de fundação:
// - confirma que CLS está populando workspaceId + requestId;
// - serve como alvo do healthcheck do docker compose / smoke test;
// - usado pela Web (Frente B) para validar conectividade real.
interface PingResponse {
status: 'ok';
service: string;
version: string;
workspaceId: string;
requestId: string;
uptimeSeconds: number;
now: string;
}
@Controller({ path: 'ping' })
export class PingController {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
@Get()
ping(): PingResponse {
return {
status: 'ok',
service: 'sar-api',
version: process.env['npm_package_version'] ?? '0.1.0',
workspaceId: this.cls.get('workspaceId'),
requestId: this.cls.get('requestId'),
uptimeSeconds: Math.round(process.uptime()),
now: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { PingController } from './ping.controller';
@Module({
controllers: [PingController],
})
export class PingModule {}

View File

@@ -0,0 +1,48 @@
import { Module } from '@nestjs/common';
import { ClsModule } from 'nestjs-cls';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { randomUUID } from 'node:crypto';
import type { Request, Response } from 'express';
import type { WorkspaceClsStore } from './workspace.types';
import type { Env } from '../config/env.schema';
// CLS popula contexto por request. Hoje: requestId + DEFAULT_WORKSPACE_ID do env.
// Amanhã: workspaceId vem do JWT (PGD-AUTHZ-002); `prisma` é resolvido pelo
// WorkspacePrismaPool e injetado via cls.set('prisma', ...) aqui mesmo.
@Module({
imports: [
ClsModule.forRootAsync({
global: true,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService<Env, true>) => ({
middleware: {
mount: true,
generateId: true,
idGenerator: (req: Request) => {
// Prioridade: req.id (pino-http já gerou/leu header) > header bruto > novo UUID.
const fromPino = (req as Request & { id?: unknown }).id;
if (typeof fromPino === 'string' && fromPino.length > 0) return fromPino;
const headerVal = req.headers['x-request-id'];
return typeof headerVal === 'string' && headerVal.length > 0
? headerVal
: randomUUID();
},
setup: (cls, req: Request, res: Response) => {
const store = cls as unknown as {
set: <K extends keyof WorkspaceClsStore>(key: K, value: WorkspaceClsStore[K]) => void;
getId: () => string;
};
const requestId = store.getId();
res.setHeader('x-request-id', requestId);
store.set('requestId', requestId);
store.set('workspaceId', config.get('DEFAULT_WORKSPACE_ID', { infer: true }));
},
},
}),
}),
],
exports: [ClsModule],
})
export class WorkspaceModule {}

View File

@@ -0,0 +1,13 @@
import type { ClsStore } from 'nestjs-cls';
// Forma do CLS store por request — fonte da verdade para qualquer caller
// que faça `cls.get(...)`. Quando o PrismaClient por workspace entrar
// (ADR 0006), `prisma` virará obrigatório aqui — por hora segue opcional.
export interface WorkspaceClsStore extends ClsStore {
requestId: string;
workspaceId: string;
// userId virá quando master-login estiver plugado.
userId?: string;
// prisma: PrismaClient — adicionar quando WorkspacePrismaPool entrar.
}

View File

@@ -1,19 +1,82 @@
/**
* This is not a production server yet!
* This is only a minimal backend to get started.
*/
// CODING-RULES §09 (PGD-OBS-001): tracing PRECISA ser o primeiro import.
// OTel NodeSDK faz monkey-patch dos módulos node:http/pg/etc. antes do Nest.
import './tracing';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { VersioningType, type INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Logger } from 'nestjs-pino';
import helmet from 'helmet';
import compression from 'compression';
import { AppModule } from './app/app.module';
import type { Env } from './app/config/env.schema';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const globalPrefix = 'api';
async function bootstrap(): Promise<void> {
const app: INestApplication = await NestFactory.create(AppModule, {
bufferLogs: true, // logs do boot vão pro Pino quando ele subir
});
// Logger Pino global (substitui o NestLogger padrão).
app.useLogger(app.get(Logger));
const config = app.get(ConfigService) as ConfigService<Env, true>;
// CORS — origens vindas do EnvSchema (já parseadas em array).
app.enableCors({
origin: config.get('CORS_ORIGINS', { infer: true }),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'X-Request-Id',
'X-Correlation-Id',
'Idempotency-Key',
],
exposedHeaders: ['X-Request-Id'],
maxAge: 600,
});
// Helmet — headers de segurança canônicos.
app.use(
helmet({
contentSecurityPolicy: false, // SPA serve CSP via Nginx; API JSON dispensa.
crossOriginEmbedderPolicy: false,
}),
);
// Compression — gzip/br.
app.use(compression());
// Versionamento via URI: /api/v1/...
const globalPrefix = config.get('API_GLOBAL_PREFIX', { infer: true });
const apiVersion = config.get('API_VERSION', { infer: true }).replace(/^v/, '');
app.setGlobalPrefix(globalPrefix);
const port = process.env.PORT || 3000;
await app.listen(port);
Logger.log(`🚀 Application is running on: http://localhost:${port}/${globalPrefix}`);
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: apiVersion,
prefix: 'v',
});
// Graceful shutdown — Nest emite SIGTERM/SIGINT → hooks rodam → dreno de conexões.
// Compose: stop_grace_period: 30s + HAProxy drain 30s (CODING-RULES §18).
app.enableShutdownHooks();
const port = config.get('API_PORT', { infer: true });
const host = config.get('API_HOST', { infer: true });
await app.listen(port, host);
app
.get(Logger)
.log(
`SAR API pronta em http://${host}:${port}/${globalPrefix}/${apiVersion === '0' ? '' : `v${apiVersion}`}`,
'Bootstrap',
);
}
bootstrap();
bootstrap().catch((err) => {
// Antes do Logger Pino estar disponível, qualquer erro de boot cai aqui.
console.error('[bootstrap] crash fatal — encerrando:', err);
process.exit(1);
});

22
apps/api/src/tracing.ts Normal file
View File

@@ -0,0 +1,22 @@
// CODING-RULES.md §09 (PGD-OBS-001):
// Este arquivo DEVE ser o primeiro import de main.ts — antes do NestJS.
// OTel NodeSDK precisa fazer monkey-patch dos módulos node:http, pg, etc.
// antes que qualquer outro código os carregue.
//
// Stub ativo:
// - Quando OTEL_EXPORTER_OTLP_ENDPOINT estiver definida, inicializa SDK real
// (a ser plugado quando @opentelemetry/sdk-node entrar no canon catalog).
// - Caso contrário, no-op silencioso (dev local).
//
// Sampling alvo (prod): parentbased_traceidratio com ARG=0.1 (10% head-based).
const otlpEndpoint = process.env['OTEL_EXPORTER_OTLP_ENDPOINT'];
if (otlpEndpoint) {
// TODO(otel): plugar @opentelemetry/sdk-node + auto-instrumentations
// quando dependência entrar no catálogo. Stub atual mantém posição correta
// do import sem instalar dependência pesada em solo-founder mode.
console.warn(
'[tracing] OTEL_EXPORTER_OTLP_ENDPOINT definido mas SDK não plugado ainda — placeholder ativo.',
);
}