Langfuse com NestJS e OpenAI: rastreie agentes de IA em TS
Traces, tool calling, tokens, custo, sessões e prompts versionados em uma API NestJS com TypeScript, sem vazar dados sensíveis

Colocar um agente de IA em produção sem observabilidade é como depurar um sistema distribuído sem logs: você sabe que algo deu errado, mas não sabe onde, quanto custou nem qual decisão do modelo levou ao erro. Neste tutorial você vai integrar o Langfuse com NestJS e TypeScript para rastrear, passo a passo, um agente da OpenAI com tool calling, registrando traces, spans, generations, tokens, custo, sessões e usuários.
A proposta é prática e reproduzível. Vamos construir uma API NestJS com um endpoint de chat que conversa com um agente de suporte de uma loja online. O agente decide sozinho quando chamar ferramentas para consultar pedidos ou calcular frete, e cada passo dessa decisão aparece no Langfuse como uma árvore de observações que você consegue inspecionar, filtrar e comparar.
Todo o código foi escrito contra as versões atuais publicadas no npm em 28 de Outubro, 2026, e compilado de verdade com o TypeScript. Ao longo do texto deixo claro o que foi executado e o que depende das suas próprias chaves de API, porque honestidade técnica vale mais do que um print bonito.
O problema: agentes de IA são caixas-pretas
Um agente com tool calling não é uma única chamada ao modelo. Ele envia a pergunta, recebe um pedido para executar uma ferramenta, executa essa ferramenta no seu backend, devolve o resultado ao modelo e repete o ciclo até chegar a uma resposta final. Uma única pergunta do usuário pode gerar três, quatro ou cinco chamadas à OpenAI, cada uma com seu custo e sua latência.
Sem rastreamento, o log da aplicação mostra apenas que a requisição demorou oito segundos. Você não sabe se o tempo foi gasto no modelo, numa ferramenta lenta ou num loop em que o agente chamou a mesma ferramenta repetidas vezes. Também não sabe qual versão do prompt estava ativa, nem qual usuário ou sessão gerou um pico de tokens na fatura do mês.
Há ainda um risco de segurança pouco discutido. Ferramentas de observabilidade guardam entradas e saídas completas, e é fácil acabar enviando e-mails, CPFs, números de cartão ou até chaves de API para um painel de terceiros. Um bom tutorial de observabilidade precisa tratar mascaramento de dados como requisito, não como detalhe.
O resultado que você terá no final
Ao final, você terá uma API NestJS com o endpoint POST /agent/chat, que valida a entrada com Zod, executa um agente com até cinco passos de raciocínio e devolve a resposta junto com o traceId do Langfuse. Esse identificador permite que o frontend ou o time de suporte abram exatamente o trace daquela conversa.
No Langfuse, cada requisição vira um trace chamado agente-suporte, com uma observação raiz do tipo agent, generations para cada chamada à OpenAI, com modelo, tokens e custo estimado, e observações do tipo tool para cada ferramenta executada. Usuário e sessão são propagados para todas as observações, e o prompt de sistema vem do Prompt Management do Langfuse, com fallback local.
Pré-requisitos e versões
Você vai precisar de Node.js 22 ou superior, porque a documentação do Langfuse indica Node.js 22 como ambiente do pacote @langfuse/otel. Também precisa de uma conta no Langfuse Cloud ou de uma instância self-hosted compatível, além de uma chave de API da OpenAI. O gerenciador de pacotes usado em todo o tutorial é o npm.
| Pacote | Versão usada |
|---|---|
| @nestjs/core, @nestjs/common, @nestjs/platform-express | 12.3.2 |
| @langfuse/tracing, @langfuse/otel, @langfuse/openai, @langfuse/client | 6.0.2 |
| @opentelemetry/sdk-node | 0.223.0 (resolvida pelo npm) |
| openai | 7.45.1 |
| zod | 4.6.7 |
| dotenv | 18.0.9 |
| typescript | 7.0.2 |
| @types/node | 22.20.5 |
Se você usa Langfuse self-hosted, confira a matriz de compatibilidade antes de começar. Segundo a documentação oficial, o SDK JS/TS v6 exige servidor Langfuse v4.59.0 ou superior; quem ainda roda Langfuse v3 deve permanecer no SDK v5. Essa é uma das causas mais comuns de traces que simplesmente não aparecem.
Arquitetura da solução
A arquitetura tem três camadas. Na borda fica o controller NestJS, responsável apenas por receber o HTTP e validar o corpo. No meio fica o AgentService, que orquestra o loop do agente e as ferramentas. Por baixo de tudo está o OpenTelemetry, com o LangfuseSpanProcessor exportando as observações para o Langfuse em lotes e em segundo plano.
O ponto central é que o SDK atual do Langfuse para TypeScript é construído sobre OpenTelemetry. Isso significa que o contexto do trace é propagado automaticamente entre funções assíncronas: tudo o que acontece dentro de startActiveObservation vira filho daquela observação, inclusive as generations criadas pelo wrapper observeOpenAI. Você não precisa passar IDs de trace manualmente entre serviços.
Cliente HTTP
│ POST /agent/chat
▼
AgentController ──(ZodValidationPipe)──► AgentService
│ propagateAttributes(userId, sessionId)
│ startActiveObservation("agent-run", asType: agent)
│ ├─ generation: agent-step (OpenAI)
│ ├─ tool: consultar_pedido
│ └─ generation: agent-step (OpenAI)
▼
OpenTelemetry NodeSDK + LangfuseSpanProcessor (mask)
▼
Langfuse
Estrutura de diretórios
O projeto foi montado manualmente, sem o Nest CLI, para deixar explícita cada dependência. Se preferir, você pode gerar o esqueleto com o CLI e depois substituir os arquivos.
langfuse-nest/
├── .env.example
├── package.json
├── tsconfig.json
└── src/
├── main.ts
├── instrumentation.ts
├── app.module.ts
├── config/env.ts
├── common/zod-validation.pipe.ts
├── observability/
│ ├── mask.ts
│ ├── observability.module.ts
│ └── observability.service.ts
└── agent/
├── agent.controller.ts
├── agent.dto.ts
├── agent.module.ts
├── agent.service.ts
└── agent.tools.ts
Preparando o ambiente
Crie a pasta, inicialize o projeto e instale as dependências com versões fixadas. Fixar versões é importante em tutoriais de IA, porque os SDKs evoluem rápido e uma major nova pode renomear funções.
mkdir langfuse-nest && cd langfuse-nest
npm init -y
npm install @nestjs/[email protected] @nestjs/[email protected] @nestjs/[email protected] reflect-metadata rxjs \
@langfuse/[email protected] @langfuse/[email protected] @langfuse/[email protected] @langfuse/[email protected] \
@opentelemetry/sdk-node [email protected] [email protected] dotenv
npm install -D [email protected] @types/node@22
npm pkg set scripts.build="tsc -p ." scripts.typecheck="tsc --noEmit -p ." scripts.start="node dist/main.js"
Arquivo tsconfig.json:
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true
},
"include": ["src/**/*.ts"]
}
As opções experimentalDecorators e emitDecoratorMetadata são necessárias para a injeção de dependência do NestJS funcionar a partir dos tipos dos construtores. Sem elas, o Nest não consegue descobrir que o AgentService depende do ObservabilityService.
Arquivo .env.example (copie para .env e preencha; nunca versione o .env):
# Servidor
PORT=3000
NODE_ENV=development
# OpenAI (https://platform.openai.com/api-keys)
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o-mini
# Langfuse (Settings > API Keys do seu projeto)
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
LANGFUSE_BASE_URL=https://cloud.langfuse.com
LANGFUSE_PROMPT_NAME=agente-suporte
A região padrão do Langfuse Cloud é a da União Europeia. Se o seu projeto foi criado na região dos Estados Unidos ou em outra região, ajuste LANGFUSE_BASE_URL conforme a documentação, ou os traces serão rejeitados por credenciais inválidas.
Passo 1: validar as variáveis de ambiente
Antes de qualquer coisa, a aplicação precisa falhar cedo e com uma mensagem clara quando falta configuração. O schema abaixo valida o formato das chaves do Langfuse e, de propósito, informa apenas o nome dos campos inválidos, nunca o valor, para não vazar segredos nos logs.
Arquivo src/config/env.ts:
import { z } from "zod";
const envSchema = z.object({
PORT: z.coerce.number().int().positive().default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
OPENAI_API_KEY: z.string().min(1, "OPENAI_API_KEY é obrigatória"),
OPENAI_MODEL: z.string().min(1).default("gpt-4o-mini"),
LANGFUSE_PUBLIC_KEY: z.string().startsWith("pk-lf-"),
LANGFUSE_SECRET_KEY: z.string().startsWith("sk-lf-"),
LANGFUSE_BASE_URL: z.url().default("https://cloud.langfuse.com"),
LANGFUSE_PROMPT_NAME: z.string().min(1).default("agente-suporte"),
});
export type Env = z.infer<typeof envSchema>;
export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
const result = envSchema.safeParse(source);
if (!result.success) {
const campos = result.error.issues.map((i) => i.path.join(".")).join(", ");
throw new Error(`Variáveis de ambiente inválidas: ${campos}`);
}
return result.data;
}
Passo 2: mascarar dados sensíveis antes do envio
O LangfuseSpanProcessor aceita uma função mask que é aplicada aos dados das observações antes da exportação. Aqui usamos expressões regulares simples para e-mail, CPF, cartão e chaves no formato da OpenAI e do Langfuse, além de remover campos cujo nome indique credencial.
Arquivo src/observability/mask.ts:
const EMAIL = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi;
const CPF = /\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b/g;
const SECRET = /\b(sk-[A-Za-z0-9_-]{10,}|sk-lf-[A-Za-z0-9-]+|pk-lf-[A-Za-z0-9-]+)\b/g;
const CARD = /\b(?:\d[ -]?){13,19}\b/g;
export function maskString(value: string): string {
return value
.replace(SECRET, "[SEGREDO]")
.replace(EMAIL, "[EMAIL]")
.replace(CPF, "[CPF]")
.replace(CARD, "[CARTAO]");
}
export function maskData(data: unknown): unknown {
if (typeof data === "string") return maskString(data);
if (Array.isArray(data)) return data.map(maskData);
if (data !== null && typeof data === "object") {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
out[key] = /authorization|api[_-]?key|password|senha|token/i.test(key)
? "[REMOVIDO]"
: maskData(value);
}
return out;
}
return data;
}
Expressões regulares não substituem uma política de dados. Elas reduzem o risco de um vazamento acidental, mas o melhor dado sensível é aquele que nem chega ao trace. Por isso, mais adiante, o DTO recusa e-mails como userId e o agente é instruído a nunca pedir CPF ou cartão. Atenção também a um detalhe da documentação do SDK: metadados propagados com propagateAttributes não passam pela máscara, então coloque ali apenas identificadores opacos.
Passo 3: inicializar o OpenTelemetry com o Langfuse
O arquivo de instrumentação cria o processor do Langfuse e inicia o NodeSDK do OpenTelemetry. As credenciais são lidas automaticamente das variáveis LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY e LANGFUSE_BASE_URL, conforme a documentação do SDK.
Arquivo src/instrumentation.ts:
import { NodeSDK } from "@opentelemetry/sdk-node";
import { LangfuseSpanProcessor } from "@langfuse/otel";
import { maskData } from "./observability/mask";
export const langfuseSpanProcessor = new LangfuseSpanProcessor({
mask: ({ data }) => maskData(data),
environment: process.env.NODE_ENV ?? "development",
});
export const otelSdk = new NodeSDK({
spanProcessors: [langfuseSpanProcessor],
});
otelSdk.start();
Este arquivo precisa ser importado antes de qualquer outro módulo da aplicação. O OpenTelemetry registra o contexto global no momento em que o SDK inicia, e importar a instrumentação tarde demais é a forma mais fácil de obter traces quebrados ou observações soltas, sem pai.
Passo 4: flush e shutdown no ciclo de vida do NestJS
O Langfuse envia eventos em lotes em segundo plano, o que é ótimo para latência, mas exige cuidado no desligamento. Se o processo for encerrado num deploy sem chamar shutdown, os últimos traces da fila podem ser perdidos. O NestJS resolve isso com o hook OnApplicationShutdown, que só dispara se enableShutdownHooks estiver ativo.
Arquivo src/observability/observability.service.ts:
import { Injectable, Logger, OnApplicationShutdown } from "@nestjs/common";
import { LangfuseClient } from "@langfuse/client";
import { langfuseSpanProcessor, otelSdk } from "../instrumentation";
@Injectable()
export class ObservabilityService implements OnApplicationShutdown {
private readonly logger = new Logger(ObservabilityService.name);
readonly langfuse = new LangfuseClient();
async flush(): Promise<void> {
await langfuseSpanProcessor.forceFlush();
}
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(`Encerrando observabilidade (sinal: ${signal ?? "nenhum"})`);
try {
await this.langfuse.shutdown();
await otelSdk.shutdown();
} catch (error) {
this.logger.error("Falha ao enviar traces pendentes", error instanceof Error ? error.stack : undefined);
}
}
}
Arquivo src/observability/observability.module.ts:
import { Global, Module } from "@nestjs/common";
import { ObservabilityService } from "./observability.service";
@Global()
@Module({
providers: [ObservabilityService],
exports: [ObservabilityService],
})
export class ObservabilityModule {}
O método flush existe para cenários de vida curta, como um job agendado ou uma função serverless, em que você quer garantir o envio antes de responder. Em uma API de longa duração, o envio em lote já resolve, e chamar forceFlush a cada requisição só aumentaria a latência.
Passo 5: validar a entrada da API com Zod
Validar a entrada protege tanto o modelo quanto a fatura. Limitar a mensagem a dois mil caracteres evita prompts gigantes, e exigir identificadores opacos para usuário e sessão evita que dados pessoais virem chave de filtro no Langfuse.
Arquivo src/common/zod-validation.pipe.ts:
import { BadRequestException, PipeTransform } from "@nestjs/common";
import type { ZodType } from "zod";
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodType<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({
message: "Payload inválido",
errors: result.error.issues.map((i) => ({ campo: i.path.join("."), erro: i.message })),
});
}
return result.data;
}
}
Arquivo src/agent/agent.dto.ts:
import { z } from "zod";
export const chatRequestSchema = z.object({
message: z.string().trim().min(1).max(2000),
sessionId: z.string().regex(/^[A-Za-z0-9_-]{8,64}$/, "sessionId deve ter 8-64 caracteres [A-Za-z0-9_-]"),
userId: z.string().regex(/^[A-Za-z0-9_-]{3,64}$/, "use um identificador opaco, nunca e-mail"),
});
export type ChatRequest = z.infer<typeof chatRequestSchema>;
export interface ChatResponse {
answer: string;
traceId: string;
toolCalls: number;
}
Passo 6: definir as ferramentas do agente
As ferramentas são descritas para a OpenAI com JSON Schema e o modo strict, que faz o modelo respeitar o formato dos argumentos. Mesmo assim, os argumentos chegam como texto gerado pelo modelo, então cada ferramenta revalida tudo com Zod antes de executar. Nunca confie cegamente em argumentos produzidos por um LLM.
Arquivo src/agent/agent.tools.ts:
import type OpenAI from "openai";
import { z } from "zod";
const orderArgsSchema = z.object({ orderId: z.string().regex(/^PED-\d{4,10}$/) });
const shippingArgsSchema = z.object({ cep: z.string().regex(/^\d{5}-?\d{3}$/) });
const FAKE_ORDERS: Record<string, { status: string; previsao: string }> = {
"PED-1001": { status: "em transporte", previsao: "2026-10-30" },
"PED-1002": { status: "entregue", previsao: "2026-10-20" },
};
export const toolDefinitions: OpenAI.Chat.Completions.ChatCompletionTool[] = [
{
type: "function",
function: {
name: "consultar_pedido",
description: "Consulta o status de um pedido pelo código no formato PED-0000.",
parameters: {
type: "object",
properties: { orderId: { type: "string", description: "Código do pedido, ex.: PED-1001" } },
required: ["orderId"],
additionalProperties: false,
},
strict: true,
},
},
{
type: "function",
function: {
name: "calcular_frete",
description: "Calcula prazo estimado de frete para um CEP brasileiro.",
parameters: {
type: "object",
properties: { cep: { type: "string", description: "CEP com 8 dígitos" } },
required: ["cep"],
additionalProperties: false,
},
strict: true,
},
},
];
export type ToolResult = { ok: true; data: unknown } | { ok: false; error: string };
export async function executeTool(name: string, rawArgs: string): Promise<ToolResult> {
let parsed: unknown;
try {
parsed = JSON.parse(rawArgs);
} catch {
return { ok: false, error: "Argumentos não são JSON válido" };
}
switch (name) {
case "consultar_pedido": {
const args = orderArgsSchema.safeParse(parsed);
if (!args.success) return { ok: false, error: "orderId inválido" };
const order = FAKE_ORDERS[args.data.orderId];
return order ? { ok: true, data: order } : { ok: false, error: "Pedido não encontrado" };
}
case "calcular_frete": {
const args = shippingArgsSchema.safeParse(parsed);
if (!args.success) return { ok: false, error: "CEP inválido" };
const regiao = Number(args.data.cep[0]);
return { ok: true, data: { prazoDiasUteis: regiao <= 3 ? 3 : 7 } };
}
default:
return { ok: false, error: `Ferramenta desconhecida: ${name}` };
}
}
Os dados de pedidos são fictícios para manter o exemplo autocontido. Em um sistema real, executeTool chamaria seus repositórios ou outros microsserviços, e qualquer instrumentação OpenTelemetry dessas chamadas apareceria automaticamente dentro da observação da ferramenta.
Passo 7: o agente com tool calling rastreado
Este é o coração do tutorial. O método chat envolve tudo em propagateAttributes, que aplica usuário, sessão, nome do trace e tags a todas as observações filhas. Dentro dele, startActiveObservation cria a observação raiz do tipo agent, e o cliente OpenAI embrulhado com observeOpenAI gera uma generation para cada chamada ao modelo, com tokens e custo.
Arquivo src/agent/agent.service.ts:
import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import OpenAI from "openai";
import { observeOpenAI } from "@langfuse/openai";
import { propagateAttributes, startActiveObservation } from "@langfuse/tracing";
import type { TextPromptClient } from "@langfuse/client";
import { loadEnv } from "../config/env";
import { ObservabilityService } from "../observability/observability.service";
import { executeTool, toolDefinitions } from "./agent.tools";
import type { ChatRequest, ChatResponse } from "./agent.dto";
const MAX_STEPS = 5;
const FALLBACK_SYSTEM_PROMPT =
"Você é um agente de suporte de uma loja online brasileira. Responda em português, " +
"de forma curta. Use as ferramentas para consultar pedidos e frete. Nunca peça CPF ou cartão.";
@Injectable()
export class AgentService {
private readonly logger = new Logger(AgentService.name);
private readonly env = loadEnv();
private readonly openai = new OpenAI({ apiKey: this.env.OPENAI_API_KEY, timeout: 30_000, maxRetries: 2 });
constructor(private readonly observability: ObservabilityService) {}
private async getSystemPrompt(): Promise<TextPromptClient> {
return this.observability.langfuse.prompt.get(this.env.LANGFUSE_PROMPT_NAME, {
type: "text",
label: "production",
cacheTtlSeconds: 300,
fallback: FALLBACK_SYSTEM_PROMPT,
});
}
async chat(req: ChatRequest): Promise<ChatResponse> {
return propagateAttributes(
{ userId: req.userId, sessionId: req.sessionId, traceName: "agente-suporte", tags: ["nestjs", "tool-calling"] },
() =>
startActiveObservation(
"agent-run",
async (agent) => {
agent.update({ input: { message: req.message } });
const prompt = await this.getSystemPrompt();
const client = observeOpenAI(this.openai, {
generationName: "agent-step",
langfusePrompt: prompt.isFallback ? undefined : prompt,
});
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: "system", content: prompt.compile() },
{ role: "user", content: req.message },
];
let toolCalls = 0;
for (let step = 0; step < MAX_STEPS; step++) {
let completion: OpenAI.Chat.Completions.ChatCompletion;
try {
completion = await client.chat.completions.create({
model: this.env.OPENAI_MODEL,
messages,
tools: toolDefinitions,
tool_choice: "auto",
temperature: 0.2,
});
} catch (error) {
const status = error instanceof OpenAI.APIError ? error.status : undefined;
this.logger.error(`Falha na OpenAI (status ${status ?? "desconhecido"})`);
agent.update({ level: "ERROR", statusMessage: `openai_error_${status ?? "unknown"}` });
throw new ServiceUnavailableException("O agente está indisponível no momento");
}
const message = completion.choices[0]?.message;
if (!message) throw new ServiceUnavailableException("Resposta vazia do modelo");
messages.push(message);
const calls = message.tool_calls ?? [];
if (calls.length === 0) {
const answer = message.content ?? "";
agent.update({ output: { answer }, metadata: { steps: step + 1, toolCalls } });
return { answer, traceId: agent.traceId, toolCalls };
}
for (const call of calls) {
if (call.type !== "function") continue;
toolCalls++;
const result = await startActiveObservation(
`tool:${call.function.name}`,
async (tool) => {
tool.update({ input: call.function.arguments });
const r = await executeTool(call.function.name, call.function.arguments);
tool.update({ output: r, level: r.ok ? "DEFAULT" : "WARNING" });
return r;
},
{ asType: "tool" },
);
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(result) });
}
}
agent.update({ level: "WARNING", statusMessage: "max_steps_reached" });
return { answer: "Não consegui concluir sua solicitação agora. Tente reformular.", traceId: agent.traceId, toolCalls };
},
{ asType: "agent" },
),
);
}
}
O prompt de sistema vem do Prompt Management com o rótulo production e cache de cinco minutos. Se o Langfuse estiver fora do ar ou o prompt ainda não existir, o SDK usa o texto de fallback e a aplicação continua funcionando. Quando o prompt real é carregado, ele é passado em langfusePrompt, o que liga cada generation à versão exata do prompt usada.
O limite de cinco passos é uma proteção contra loops. Agentes às vezes insistem em chamar a mesma ferramenta, e sem um teto essa insistência vira latência e custo. Quando o limite é atingido, a observação recebe nível WARNING, o que facilita criar filtros no Langfuse para encontrar exatamente essas conversas problemáticas.
Repare no tratamento de erros da OpenAI. O log registra apenas o status HTTP, e não a mensagem completa do erro, que pode conter trechos da requisição. O cliente recebe uma resposta 503 genérica, enquanto a observação no Langfuse fica marcada como ERROR com um código curto, suficiente para investigação sem expor detalhes internos.
Passo 8: controller, módulos e bootstrap
Arquivo src/agent/agent.controller.ts:
import { Body, Controller, HttpCode, Post } from "@nestjs/common";
import { ZodValidationPipe } from "../common/zod-validation.pipe";
import { AgentService } from "./agent.service";
import { chatRequestSchema, type ChatRequest, type ChatResponse } from "./agent.dto";
@Controller("agent")
export class AgentController {
constructor(private readonly agentService: AgentService) {}
@Post("chat")
@HttpCode(200)
chat(@Body(new ZodValidationPipe(chatRequestSchema)) body: ChatRequest): Promise<ChatResponse> {
return this.agentService.chat(body);
}
}
Arquivo src/agent/agent.module.ts:
import { Module } from "@nestjs/common";
import { AgentController } from "./agent.controller";
import { AgentService } from "./agent.service";
@Module({ controllers: [AgentController], providers: [AgentService] })
export class AgentModule {}
Arquivo src/app.module.ts:
import { Module } from "@nestjs/common";
import { ObservabilityModule } from "./observability/observability.module";
import { AgentModule } from "./agent/agent.module";
@Module({ imports: [ObservabilityModule, AgentModule] })
export class AppModule {}
Arquivo src/main.ts:
import "dotenv/config";
import "./instrumentation";
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { loadEnv } from "./config/env";
async function bootstrap(): Promise<void> {
const env = loadEnv();
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(env.PORT);
}
bootstrap().catch((error: unknown) => {
console.error("Falha ao iniciar a aplicação", error instanceof Error ? error.message : error);
process.exit(1);
});
A ordem dos imports em main.ts é intencional: primeiro o dotenv, para que as variáveis existam; depois a instrumentação, para que o OpenTelemetry inicie antes do NestJS; e só então o restante da aplicação. Como o projeto compila para CommonJS, essa ordem é respeitada em tempo de execução.
Executando o projeto
Com o .env preenchido, compile e suba a API:
cp .env.example .env
npm run typecheck
npm run build
npm start
Em outro terminal, envie uma pergunta que obriga o agente a usar uma ferramenta:
curl -s -X POST http://localhost:3000/agent/chat \
-H "content-type: application/json" \
-d '{"message":"Qual o status do pedido PED-1001?","sessionId":"sessao-demo-001","userId":"cliente-42"}'
A resposta traz o texto do agente, o traceId e a quantidade de ferramentas chamadas. Com chaves válidas, o trace deve aparecer no Langfuse em poucos segundos, agrupado na sessão sessao-demo-001. Na criação deste tutorial não havia chaves reais disponíveis, então essa chamada ponta a ponta não foi executada; o que foi verificado está descrito na próxima seção.
Testes e verificação
Na preparação deste artigo, o projeto foi criado do zero, npm run typecheck e npm run build terminaram sem erros com TypeScript 7.0.2, e a aplicação subiu com credenciais falsas. Uma requisição inválida retornou 400 com a lista de campos rejeitados, e o encerramento com SIGINT disparou o hook de shutdown da observabilidade.
Você pode reproduzir o teste de validação sem gastar nenhum token, porque a requisição é rejeitada pelo pipe antes de chegar ao agente:
curl -s -X POST http://localhost:3000/agent/chat \
-H "content-type: application/json" \
-d '{"message":"","sessionId":"abc","userId":"[email protected]"}'
Também vale testar a função de máscara isoladamente depois do build. O comando abaixo deve imprimir o e-mail e o CPF substituídos por marcadores e o campo apiKey removido:
node -e "const { maskData } = require('./dist/observability/mask'); console.log(maskData({ m: 'email [email protected] cpf 123.456.789-09', apiKey: 'x' }))"
Para testes automatizados, o caminho mais simples é isolar executeTool e maskData, que são funções puras, e testar o AgentService com um cliente OpenAI falso. Em ambiente de teste, você pode desligar a exportação de traces deixando de importar a instrumentação ou usando um projeto separado no Langfuse.
Erros comuns
O erro mais frequente é o trace não aparecer. Na maioria das vezes a causa é região errada em LANGFUSE_BASE_URL, chaves de outro projeto, servidor self-hosted antigo para o SDK v6 ou processo encerrado antes do flush. Confira os logs do SDK e garanta que enableShutdownHooks está ativo.
Outro problema comum são generations aparecendo como traces separados, sem a observação raiz. Isso acontece quando a chamada à OpenAI é feita fora do callback de startActiveObservation, ou quando a instrumentação foi importada depois de outros módulos, quebrando a propagação de contexto.
Também é comum o custo aparecer zerado. O Langfuse calcula custo a partir do nome do modelo e da tabela de preços configurada; se você usa um modelo novo ou um nome personalizado, pode ser preciso cadastrar a definição de modelo nas configurações do projeto. Em respostas com streaming, lembre-se de enviar stream_options: { include_usage: true }, como orienta a documentação da integração.
| Sintoma | Causa provável | Correção |
|---|---|---|
| Nenhum trace no painel | Região/base URL ou chaves erradas | Ajustar LANGFUSE_BASE_URL e chaves do projeto |
| Traces somem em deploy | Processo encerrado sem shutdown | app.enableShutdownHooks() e otelSdk.shutdown() |
| Generations soltas | Instrumentação importada tarde | Importar ./instrumentation no topo de main.ts |
| Custo zerado | Modelo sem preço cadastrado | Cadastrar definição de modelo no Langfuse |
| 400 no endpoint | Payload fora do schema | Ver lista errors da resposta |
Segurança
Trate o Langfuse como um banco de dados de produção. A chave secreta fica apenas no backend e nunca deve ir para o navegador; a própria documentação recomenda usar o pacote de browser, com chave pública, somente para enviar avaliações a partir do frontend. Use projetos separados para desenvolvimento e produção, com chaves diferentes.
Além da máscara, reduza o que é enviado. Use identificadores opacos para usuário, não envie documentos completos quando um resumo basta e revise periodicamente amostras de traces procurando dados que escaparam. Se estiver sujeito à LGPD, defina retenção de dados no projeto e documente quais informações trafegam para o Langfuse.
Performance e custos
O SDK exporta em lote e de forma assíncrona, então o impacto na latência da requisição é pequeno. O custo real está no volume: cada passo do agente gera uma generation com prompt e resposta completos. Em sistemas de alto tráfego, avalie amostragem e use shouldExportSpan para descartar spans que não interessam.
No lado da OpenAI, o painel do Langfuse passa a ser sua principal ferramenta de controle de custo. Filtrando por usuário, sessão ou versão do prompt, você descobre quais conversas consomem mais tokens, compara modelos e mede se uma mudança de prompt reduziu o número de passos do agente.
Limitações desta abordagem
Este exemplo usa a Chat Completions API, que tem suporte documentado no wrapper observeOpenAI. A integração também rastreia a Responses API, mas a OpenAI Assistants API não é coberta por ela, segundo a própria documentação. O loop do agente é síncrono dentro da requisição HTTP, o que serve para conversas curtas, mas tarefas longas pedem filas e workers.
As ferramentas aqui são fictícias e o mascaramento por regex não é exaustivo. Além disso, nenhuma chamada real à OpenAI ou ao Langfuse foi feita durante a escrita, então números de latência e custo dependem do seu modelo, da sua região e do seu tráfego.
Conclusão
Integrar o Langfuse com NestJS e OpenAI exige pouco código, mas muda completamente a forma de operar um agente de IA. Com a instrumentação certa, cada pergunta do usuário vira um trace navegável, com passos, ferramentas, tokens, custo e versão do prompt, e o ciclo de vida do NestJS garante que nada se perca no desligamento.
O próximo passo natural é usar esses traces para avaliar qualidade: registrar scores de feedback do usuário, montar datasets a partir de conversas reais e rodar experimentos comparando prompts. Com a base deste tutorial, essas evoluções passam a ser incrementais, sem reescrever o agente.
Referências oficiais
- Langfuse – SDKs (setup JS/TS v6): https://langfuse.com/docs/observability/sdk/overview (consultado em 28 de Outubro, 2026)
- Langfuse – Instrumentation: https://langfuse.com/docs/observability/sdk/instrumentation (consultado em 28 de Outubro, 2026)
- Langfuse – Observability for OpenAI SDK (JS/TS): https://langfuse.com/integrations/model-providers/openai-js (consultado em 28 de Outubro, 2026)
- Langfuse – Prompt Management: https://langfuse.com/docs/prompt-management/overview (consultado em 28 de Outubro, 2026)
- NestJS – Lifecycle events: https://docs.nestjs.com/fundamentals/lifecycle-events (consultado em 28 de Outubro, 2026)
- NestJS – Pipes: https://docs.nestjs.com/pipes (consultado em 28 de Outubro, 2026)
- OpenAI – Function calling: https://platform.openai.com/docs/guides/function-calling (consultado em 28 de Outubro, 2026)
- SDK openai-node: https://github.com/openai/openai-node (consultado em 28 de Outubro, 2026)
- OpenTelemetry JS: https://opentelemetry.io/docs/languages/js/ (consultado em 28 de Outubro, 2026)