fg-tracker-mcp 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +15 -10
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +5 -1
- package/dist/client.js +3 -3
- package/dist/client.js.map +1 -1
- package/dist/database.js +1 -1
- package/dist/database.js.map +1 -1
- package/dist/docs.js +22 -22
- package/dist/docs.js.map +1 -1
- package/dist/format.d.ts +43 -1
- package/dist/format.js +81 -6
- package/dist/format.js.map +1 -1
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +17 -17
- package/dist/guide.js.map +1 -1
- package/dist/infra/handover.d.ts +1 -1
- package/dist/infra/handover.js +25 -23
- package/dist/infra/handover.js.map +1 -1
- package/dist/infra/iac.js +2 -2
- package/dist/infra/iac.js.map +1 -1
- package/dist/infra/instrument.js +5 -5
- package/dist/infra/instrument.js.map +1 -1
- package/dist/infra/tools.js +24 -24
- package/dist/infra/tools.js.map +1 -1
- package/dist/loadtest/scenario.js +1 -1
- package/dist/loadtest/scenario.js.map +1 -1
- package/dist/loadtest/tools.js +6 -6
- package/dist/loadtest/tools.js.map +1 -1
- package/dist/server.d.ts +12 -2
- package/dist/server.js +453 -110
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -1,37 +1,149 @@
|
|
|
1
1
|
import { detectDatabases, databaseGuide } from "./database.js";
|
|
2
2
|
/**
|
|
3
|
-
* Servidor MCP do FG
|
|
3
|
+
* Servidor MCP do FG Tracker (docs/CONTRATO.md §12): tools, prompt e resource.
|
|
4
4
|
* O transporte (stdio) é conectado em `cli.ts`.
|
|
5
5
|
*/
|
|
6
6
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
7
|
import { z } from 'zod';
|
|
8
|
-
import { RANGE_KEYS, RANGE_SECONDS, TrackApiError, TrackClient, configFromEnv, } from './client.js';
|
|
8
|
+
import { RANGE_KEYS, RANGE_SECONDS, TrackApiError, TrackClient, configFromEnv, readEnv, } from './client.js';
|
|
9
9
|
import { LEGACY_QUICKSTART_URI, QUICKSTART_URI, instrumentProjectPrompt, quickstartMarkdown } from './docs.js';
|
|
10
|
-
import { ago, compact, guard, json, ms, pct, text } from './format.js';
|
|
10
|
+
import { MAX_ITEMS_CEILING, MAX_TRACE_SPANS, ago, cappedNote, clampLimit, compact, guard, json, listPage, maxItemsFromEnv, ms, pct, preview, text, truncateList, until, } from './format.js';
|
|
11
11
|
import { FRAMEWORKS, buildGuide } from './guide.js';
|
|
12
12
|
import { registerInfraTools } from './infra/tools.js';
|
|
13
13
|
import { registerLoadtestTools } from './loadtest/tools.js';
|
|
14
14
|
export const SERVER_NAME = 'fg-tracker';
|
|
15
|
-
export const SERVER_VERSION = '0.
|
|
15
|
+
export const SERVER_VERSION = '0.10.0';
|
|
16
16
|
const rangeSchema = z.enum(RANGE_KEYS).default('24h').describe('Janela: 15m|1h|6h|24h|7d|30d (default 24h)');
|
|
17
|
-
const projectIdSchema = z.string().uuid().describe('ID (uuid) do projeto — veja
|
|
17
|
+
const projectIdSchema = z.string().uuid().describe('ID (uuid) do projeto — veja tracker_list_projects');
|
|
18
|
+
const rootKindSchema = z
|
|
19
|
+
.enum(['http', 'custom', 'all'])
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Tipo da raiz: http = requisições (default), custom = jobs/tarefas (tracker.span("custom", ...) sem pai), all = qualquer raiz');
|
|
22
|
+
const serviceFilterSchema = z.string().min(1).max(120).optional().describe('Só execuções deste serviço (serviceName da raiz, igualdade exata; valores em `services` de tracker_get_overview)');
|
|
23
|
+
const envFilterSchema = z.string().min(1).max(64).optional().describe('Só execuções deste ambiente (environment da raiz, igualdade exata; valores em `environments` de tracker_get_overview)');
|
|
24
|
+
/** Descreve os filtros de raiz aplicados, para as mensagens de resumo. */
|
|
25
|
+
function filterNote(filters) {
|
|
26
|
+
const parts = [
|
|
27
|
+
filters.rootKind && filters.rootKind !== 'http' ? `rootKind ${filters.rootKind}` : '',
|
|
28
|
+
filters.service ? `service ${filters.service}` : '',
|
|
29
|
+
filters.env ? `env ${filters.env}` : '',
|
|
30
|
+
].filter(Boolean);
|
|
31
|
+
return parts.length ? ` (${parts.join(', ')})` : '';
|
|
32
|
+
}
|
|
33
|
+
/** Valores para o resumo em texto com o mesmo corte do JSON (`compact`), avisando quantos ficaram de fora. */
|
|
34
|
+
function joinCapped(values) {
|
|
35
|
+
if (!values?.length)
|
|
36
|
+
return '—';
|
|
37
|
+
const { items, truncated } = truncateList(values);
|
|
38
|
+
return `${items.join(', ')}${truncated > 0 ? ` (+${truncated} omitidos)` : ''}`;
|
|
39
|
+
}
|
|
18
40
|
const LOG_LEVELS = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
|
|
41
|
+
const ISSUE_TYPES = ['N_PLUS_ONE', 'LOOP', 'SLOW_ENDPOINT', 'UNTRACED_GAP', 'ERROR'];
|
|
42
|
+
const ISSUE_SEVERITIES = ['low', 'medium', 'high', 'critical'];
|
|
43
|
+
const ISSUE_SORTS = ['recent', 'impact', 'count', 'first'];
|
|
44
|
+
/** `high · última medium (55%)`: a máxima histórica e, quando houver, a última detecção. */
|
|
45
|
+
function severityLabel(issue) {
|
|
46
|
+
const metric = issue.lastMetric;
|
|
47
|
+
const measure = !metric
|
|
48
|
+
? ''
|
|
49
|
+
: metric.unit === 'ratio'
|
|
50
|
+
? `${Math.round(metric.value * 100)}%`
|
|
51
|
+
: metric.unit === 'ms'
|
|
52
|
+
? ms(metric.value)
|
|
53
|
+
: `×${metric.value}`;
|
|
54
|
+
if (issue.lastSeverity && issue.lastSeverity !== issue.severity)
|
|
55
|
+
return `${issue.severity} · última ${issue.lastSeverity}${measure ? ` (${measure})` : ''}`;
|
|
56
|
+
return measure ? `${issue.severity} (última ${measure})` : issue.severity;
|
|
57
|
+
}
|
|
19
58
|
function rangeWindow(range, now = Date.now()) {
|
|
20
59
|
return { from: new Date(now - RANGE_SECONDS[range] * 1000).toISOString(), to: new Date(now).toISOString() };
|
|
21
60
|
}
|
|
61
|
+
/** Prefixo das ferramentas próprias do FG Tracker (`infra_*` e `loadtest_*` têm o seu). */
|
|
62
|
+
export const TOOL_PREFIX = 'tracker_';
|
|
63
|
+
/**
|
|
64
|
+
* @deprecated prefixo até a 0.9 (`track_status`, `track_list_projects`…). Os nomes antigos seguem
|
|
65
|
+
* registrados como aliases, para clientes com permissões ou prompts salvos com eles; saem na 1.0.
|
|
66
|
+
*/
|
|
67
|
+
export const LEGACY_TOOL_PREFIX = 'track_';
|
|
68
|
+
export function legacyToolNamesFromEnv(env = process.env) {
|
|
69
|
+
const value = readEnv('LEGACY_TOOL_NAMES', env)?.toLowerCase();
|
|
70
|
+
return !(value === '0' || value === 'false' || value === 'off');
|
|
71
|
+
}
|
|
72
|
+
/** Cada `tracker_x` registrado ganha o alias `track_x`, com o mesmo schema e handler. */
|
|
73
|
+
function registerLegacyToolAliases(server) {
|
|
74
|
+
const register = server.registerTool.bind(server);
|
|
75
|
+
server.registerTool = (name, config, handler) => {
|
|
76
|
+
const tool = register(name, config, handler);
|
|
77
|
+
if (name.startsWith(TOOL_PREFIX)) {
|
|
78
|
+
const legacy = LEGACY_TOOL_PREFIX + name.slice(TOOL_PREFIX.length);
|
|
79
|
+
// Descrição curta: o alias só existe para quem já chama o nome antigo, e repetir a descrição
|
|
80
|
+
// inteira aumentaria em cerca de metade o tools/list de todo cliente.
|
|
81
|
+
register(legacy, {
|
|
82
|
+
...config,
|
|
83
|
+
title: config.title ? `${config.title} (nome obsoleto)` : undefined,
|
|
84
|
+
description: `Nome obsoleto: use ${name} (${legacy} sai na 1.0). Mesmos parâmetros e mesmo resultado.`,
|
|
85
|
+
}, handler);
|
|
86
|
+
}
|
|
87
|
+
return tool;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Validade da credencial de FG_TRACKER_TOKEN (GET /v1/auth/me), sem mostrar o token. */
|
|
91
|
+
async function describeCredential(client, now = Date.now()) {
|
|
92
|
+
let me;
|
|
93
|
+
try {
|
|
94
|
+
me = await client.get('/v1/auth/me');
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err instanceof TrackApiError && err.status === 404)
|
|
98
|
+
return 'Validade do token: desconhecida (esta API não expõe GET /v1/auth/me).';
|
|
99
|
+
return `Validade do token: ${err instanceof TrackApiError ? err.describe() : String(err)}`;
|
|
100
|
+
}
|
|
101
|
+
const credential = me.credential ?? null;
|
|
102
|
+
const isToken = credential?.type === 'token';
|
|
103
|
+
let label = me.session || credential?.type === 'session' ? 'sessão' : 'credencial';
|
|
104
|
+
if (isToken) {
|
|
105
|
+
const projects = credential.projectIds === null ? 'todos os projetos' : Array.isArray(credential.projectIds) ? `${credential.projectIds.length} projeto(s)` : '';
|
|
106
|
+
const scope = [credential.scope ? `escopo ${credential.scope}` : '', projects].filter(Boolean).join(', ');
|
|
107
|
+
label = `token pessoal${credential.name ? ` "${credential.name}"` : ''}${scope ? ` (${scope})` : ''}`;
|
|
108
|
+
}
|
|
109
|
+
const renewal = isToken ? '' : ' Cada uso na segunda metade da validade renova a sessão; logout e troca de senha a revogam.';
|
|
110
|
+
const expiresAt = credential?.expiresAt ?? me.session?.expiresAt ?? null;
|
|
111
|
+
if (!expiresAt)
|
|
112
|
+
return `Validade do token: ${label}, sem data de expiração.`;
|
|
113
|
+
const t = Date.parse(expiresAt);
|
|
114
|
+
if (Number.isNaN(t))
|
|
115
|
+
return `Validade do token: ${label}, expira em ${expiresAt}.`;
|
|
116
|
+
if (t <= now)
|
|
117
|
+
return `Validade do token: ${label} expirou ${ago(expiresAt, now)} (${expiresAt}). Gere um novo e atualize FG_TRACKER_TOKEN.`;
|
|
118
|
+
const soon = t - now < 2 * 86_400_000 ? ' ATENÇÃO: perto de expirar; gere um novo e atualize FG_TRACKER_TOKEN.' : '';
|
|
119
|
+
return `Validade do token: ${label}, expira ${until(expiresAt, now)} (${expiresAt}).${soon}${renewal}`;
|
|
120
|
+
}
|
|
121
|
+
function monitorLabel(m) {
|
|
122
|
+
const env = 'environment' in m ? ` · ${m.environment ?? 'sem ambiente'}` : '';
|
|
123
|
+
return `[${m.status}] ${m.name} (${m.kind}, ${m.slug}${env})`;
|
|
124
|
+
}
|
|
125
|
+
/** Execução mais recente que reportou trace (`recent` vem do mais antigo para o mais novo). */
|
|
126
|
+
function lastTraced(recent) {
|
|
127
|
+
return [...(recent ?? [])].reverse().find((check) => check.traceId);
|
|
128
|
+
}
|
|
22
129
|
export function createServer(options = {}) {
|
|
23
130
|
const client = options.client ?? new TrackClient(configFromEnv());
|
|
131
|
+
const outputLimit = maxItemsFromEnv();
|
|
132
|
+
const maxItems = outputLimit.value;
|
|
133
|
+
const legacyToolNames = options.legacyToolNames ?? legacyToolNamesFromEnv();
|
|
24
134
|
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, {
|
|
25
135
|
instructions: [
|
|
26
|
-
'Servidor MCP do FG
|
|
27
|
-
`API: ${client.baseUrl}. Comece por
|
|
28
|
-
'Para conectar um app use o prompt instrument-project ou
|
|
136
|
+
'Servidor MCP do FG Tracker (APM para Node: traces distribuídos, queries de qualquer banco via adapters, provedores externos, logs, issues com impacto por usuário e commits suspeitos, releases, monitores de uptime/jobs, detecção de N+1/loops/lentidão/erros).',
|
|
137
|
+
`API: ${client.baseUrl}. Comece por tracker_status; depois tracker_list_projects para obter projectId.`,
|
|
138
|
+
'Para conectar um app use o prompt instrument-project ou tracker_detect_databases + tracker_instrumentation_guide + tracker_verify_ingest.',
|
|
29
139
|
'Ranges aceitos: 15m|1h|6h|24h|7d|30d.',
|
|
30
|
-
'Infraestrutura AWS: prompt connect-infrastructure ou tools infra_* (nenhuma altera recursos na AWS; mudanças só com aprovação humana no FG
|
|
140
|
+
'Infraestrutura AWS: prompt connect-infrastructure ou tools infra_* (nenhuma altera recursos na AWS; mudanças só com aprovação humana no FG Tracker).',
|
|
31
141
|
'Testes de carga (Simular ambiente): prompt create-load-test ou tools loadtest_* (loadtest_start_run só com confirmação explícita do usuário).',
|
|
32
142
|
].join(' '),
|
|
33
143
|
});
|
|
34
|
-
|
|
144
|
+
if (legacyToolNames)
|
|
145
|
+
registerLegacyToolAliases(server);
|
|
146
|
+
server.registerTool('tracker_detect_databases', {
|
|
35
147
|
title: 'Identify database drivers in the local project',
|
|
36
148
|
description: 'Inspect local manifests/imports without reading secrets. Returns all candidate drivers, initialization files and the correct SDK integration guide. The coding agent must apply the guide to the active clients and verify request-linked db.query spans.',
|
|
37
149
|
inputSchema: { projectPath: z.string().min(1).describe('Absolute path of the application authorized for instrumentation'), serviceName: z.string().default('app') },
|
|
@@ -42,9 +154,9 @@ export function createServer(options = {}) {
|
|
|
42
154
|
(detected.databases.length ? detected.databases.map(x => databaseGuide(x.package, serviceName)).join('\n\n') : databaseGuide('generic', serviceName)));
|
|
43
155
|
}));
|
|
44
156
|
/* ---------------- tools ---------------- */
|
|
45
|
-
server.registerTool('
|
|
46
|
-
title: 'Status do FG
|
|
47
|
-
description: 'Verifica se a API do FG
|
|
157
|
+
server.registerTool('tracker_status', {
|
|
158
|
+
title: 'Status do FG Tracker',
|
|
159
|
+
description: 'Verifica se a API do FG Tracker está no ar (GET /v1/health), qual URL/credenciais este servidor está usando e quantos projetos o token enxerga. Comece por aqui.',
|
|
48
160
|
inputSchema: {},
|
|
49
161
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
50
162
|
}, async () => guard(async () => {
|
|
@@ -53,7 +165,10 @@ export function createServer(options = {}) {
|
|
|
53
165
|
`API: ${client.baseUrl} — ${health.status} (versão ${health.version}, banco ${health.database}, uptime ${Math.round(health.uptimeSeconds)} s)`,
|
|
54
166
|
`FG_TRACKER_TOKEN: ${client.hasToken ? 'definido' : 'ausente (necessário quando a API exige login: projetos/métricas/logs)'}`,
|
|
55
167
|
`FG_TRACKER_API_KEY: ${client.hasApiKey ? 'definido' : 'ausente (opcional; só para ingestão)'}`,
|
|
168
|
+
`Itens por lista (FG_TRACKER_MAX_ITEMS): até ${maxItems}${outputLimit.warning ? ` — ${outputLimit.warning}` : ''}`,
|
|
56
169
|
];
|
|
170
|
+
if (client.hasToken)
|
|
171
|
+
lines.push(await describeCredential(client));
|
|
57
172
|
try {
|
|
58
173
|
const projects = await client.get('/v1/projects');
|
|
59
174
|
lines.push(`Projetos acessíveis: ${projects.length}${projects.length ? ' — ' + projects.map((p) => `${p.name} (${p.id})`).slice(0, 10).join(', ') : ''}`);
|
|
@@ -70,7 +185,7 @@ export function createServer(options = {}) {
|
|
|
70
185
|
}
|
|
71
186
|
return text(lines.join('\n'));
|
|
72
187
|
}));
|
|
73
|
-
server.registerTool('
|
|
188
|
+
server.registerTool('tracker_list_projects', {
|
|
74
189
|
title: 'Listar projetos',
|
|
75
190
|
description: 'Lista os projetos que o usuário do token enxerga, com apiKey, limiares, domínios permitidos (allowedOrigins: CORS do SDK de navegador) e estatísticas de 24h (requests, taxa de erro, p95, issues abertas).',
|
|
76
191
|
inputSchema: {},
|
|
@@ -78,8 +193,10 @@ export function createServer(options = {}) {
|
|
|
78
193
|
}, async () => guard(async () => {
|
|
79
194
|
const projects = await client.get('/v1/projects');
|
|
80
195
|
if (projects.length === 0)
|
|
81
|
-
return text('Nenhum projeto. Crie um com
|
|
196
|
+
return text('Nenhum projeto. Crie um com tracker_create_project { name }.');
|
|
197
|
+
const page = listPage(projects, maxItems);
|
|
82
198
|
const summary = projects
|
|
199
|
+
.slice(0, maxItems)
|
|
83
200
|
.map((p) => {
|
|
84
201
|
const s = p.stats;
|
|
85
202
|
const stats = s ? ` · 24h: ${s.requests24h} req, erro ${pct(s.errorRate24h)}, p95 ${ms(s.p95Ms24h)}, ${s.openIssues} issues` : '';
|
|
@@ -87,21 +204,22 @@ export function createServer(options = {}) {
|
|
|
87
204
|
return `- ${p.name} — id ${p.id} · slug ${p.slug} · apiKey ${p.apiKey}${stats}${origins}`;
|
|
88
205
|
})
|
|
89
206
|
.join('\n');
|
|
90
|
-
|
|
207
|
+
const more = page.truncated ? `\nMostrando ${page.returned} de ${page.total} projetos (FG_TRACKER_MAX_ITEMS=${maxItems}).` : '';
|
|
208
|
+
return json(page, summary + more);
|
|
91
209
|
}));
|
|
92
|
-
server.registerTool('
|
|
210
|
+
server.registerTool('tracker_create_project', {
|
|
93
211
|
title: 'Criar projeto',
|
|
94
|
-
description: 'Cria um projeto no FG
|
|
212
|
+
description: 'Cria um projeto no FG Tracker e devolve o projeto com a apiKey (stk_...) para colocar em FG_TRACKER_KEY do app instrumentado.',
|
|
95
213
|
inputSchema: { name: z.string().min(1).max(120).describe('Nome do projeto (ex.: "minha-api")') },
|
|
96
214
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
97
215
|
}, async ({ name }) => guard(async () => {
|
|
98
216
|
const project = await client.post('/v1/projects', { name });
|
|
99
|
-
return json(project, `Projeto "${project.name}" criado.\n- projectId: ${project.id}\n- apiKey: ${project.apiKey}\n\nPróximo passo:
|
|
217
|
+
return json(project, `Projeto "${project.name}" criado.\n- projectId: ${project.id}\n- apiKey: ${project.apiKey}\n\nPróximo passo: tracker_instrumentation_guide { framework, projectId: "${project.id}" }.`);
|
|
100
218
|
}));
|
|
101
|
-
server.registerTool('
|
|
219
|
+
server.registerTool('tracker_update_allowed_origins', {
|
|
102
220
|
title: 'Definir domínios permitidos do projeto',
|
|
103
221
|
description: 'SUBSTITUI a lista de domínios permitidos (allowedOrigins) do projeto: origens de navegador que podem enviar pageviews/traces pelo SDK de navegador (CORS de POST /v1/ingest). ' +
|
|
104
|
-
'Antes de chamar, leia a lista atual com
|
|
222
|
+
'Antes de chamar, leia a lista atual com tracker_list_projects, monte a lista completa (atual + novas − removidas) e confirme com o usuário — origens omitidas deixam de ser aceitas. ' +
|
|
105
223
|
'Cada origem é `scheme://host[:porta]` sem caminho: https:// para qualquer host, http:// só localhost/127.0.0.1, curinga de subdomínio `https://*.dominio.com` (não inclui o domínio raiz), máx. 100. Lista vazia remove todas. Requer owner/admin. ' +
|
|
106
224
|
'Pode levar até 30 s para valer em todas as instâncias da API.',
|
|
107
225
|
inputSchema: {
|
|
@@ -114,129 +232,229 @@ export function createServer(options = {}) {
|
|
|
114
232
|
const saved = project.allowedOrigins ?? [];
|
|
115
233
|
return json({ id: project.id, name: project.name, allowedOrigins: saved }, `Domínios permitidos de "${project.name}" (${saved.length}):${saved.length ? '\n' + saved.map((origin) => `- ${origin}`).join('\n') : ' nenhum (só TRACK_INGEST_ORIGINS da API vale)'}`);
|
|
116
234
|
}));
|
|
117
|
-
server.registerTool('
|
|
235
|
+
server.registerTool('tracker_get_overview', {
|
|
118
236
|
title: 'Visão geral do projeto',
|
|
119
|
-
description: 'Métricas agregadas do projeto no range: requests, taxa de erro, p50/p95/p99, apdex, throughput, issues abertas, breakdown por status, top endpoints lentos e com erro
|
|
120
|
-
|
|
237
|
+
description: 'Métricas agregadas do projeto no range: requests HTTP, taxa de erro, p50/p95/p99, apdex, throughput, issues abertas, breakdown por status, top endpoints lentos e com erro e série temporal (amostrada). ' +
|
|
238
|
+
'Jobs (raízes custom) vêm à parte em `jobs` (execuções, erros, p50/p95 e última rodada) e não entram em requests/apdex/status. ' +
|
|
239
|
+
'`services`/`environments` listam os valores vistos no range para usar em service/env; os filtros valem para HTTP e jobs, issues abertas são do projeto inteiro.',
|
|
240
|
+
inputSchema: { projectId: projectIdSchema, range: rangeSchema, service: serviceFilterSchema, env: envFilterSchema },
|
|
121
241
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
122
|
-
}, async ({ projectId, range }) => guard(async () => {
|
|
123
|
-
const overview = await client.get(`/v1/projects/${projectId}/overview`, { range });
|
|
242
|
+
}, async ({ projectId, range, service, env }) => guard(async () => {
|
|
243
|
+
const overview = await client.get(`/v1/projects/${projectId}/overview`, { range, service, env });
|
|
124
244
|
const o = overview;
|
|
125
|
-
const
|
|
126
|
-
|
|
245
|
+
const lines = [
|
|
246
|
+
`Range ${range}${filterNote({ service, env })}: ${o.requests ?? 0} requests · erro ${pct(o.errorRate)} · p50 ${ms(o.p50Ms)} · p95 ${ms(o.p95Ms)} · p99 ${ms(o.p99Ms)} · apdex ${o.apdex ?? '—'} · ${o.openIssues ?? 0} issues abertas`,
|
|
247
|
+
];
|
|
248
|
+
if (o.jobs) {
|
|
249
|
+
lines.push(o.jobs.executions === 0
|
|
250
|
+
? 'Jobs (raízes custom): nenhuma execução no range.'
|
|
251
|
+
: `Jobs (raízes custom): ${o.jobs.executions} execuções de ${o.jobs.names} jobs · erro ${pct(o.jobs.errorRate)} · última ${o.jobs.lastRunAt ? ago(o.jobs.lastRunAt) : '—'}`, ...o.jobs.items.map((j) => `- ${j.name}: ${j.executions}× · erro ${pct(j.errorRate)} · p50 ${ms(j.p50Ms)} · p95 ${ms(j.p95Ms)} · última ${ago(j.lastRunAt)}`));
|
|
252
|
+
if (o.jobs.names > o.jobs.items.length)
|
|
253
|
+
lines.push(`(+${o.jobs.names - o.jobs.items.length} jobs: tracker_list_endpoints { rootKind: "custom" })`);
|
|
254
|
+
}
|
|
255
|
+
if (o.services?.length || o.environments?.length) {
|
|
256
|
+
lines.push(`Serviços: ${joinCapped(o.services)} · Ambientes: ${joinCapped(o.environments)}`);
|
|
257
|
+
}
|
|
258
|
+
return json(compact(overview), lines.join('\n'));
|
|
127
259
|
}));
|
|
128
|
-
server.registerTool('
|
|
260
|
+
server.registerTool('tracker_list_endpoints', {
|
|
129
261
|
title: 'Listar endpoints',
|
|
130
|
-
description: 'Endpoints do projeto no range com requests, taxa de erro, p50/p95/p99, média, último acesso, sparkline de p95 e nº de issues. Ordenável e filtrável por texto.'
|
|
262
|
+
description: 'Endpoints do projeto no range com requests, erros, taxa de erro, p50/p95/p99, média, último acesso (lastSeen), sparkline de p95 e nº de issues. Ordenável e filtrável por texto. ' +
|
|
263
|
+
'rootKind: "custom" lista jobs/tarefas (requests = execuções, lastSeen = última rodada; spans filhos não contam), "all" mistura (campo kind). Filtros service/env valem para a raiz.',
|
|
131
264
|
inputSchema: {
|
|
132
265
|
projectId: projectIdSchema,
|
|
133
266
|
range: rangeSchema,
|
|
267
|
+
rootKind: rootKindSchema,
|
|
268
|
+
service: serviceFilterSchema,
|
|
269
|
+
env: envFilterSchema,
|
|
134
270
|
search: z.string().optional().describe('Filtro textual no nome (ex.: "/users")'),
|
|
135
271
|
sort: z.enum(['p95', 'requests', 'errorRate', 'name']).optional().describe('Ordenação (default p95)'),
|
|
136
|
-
limit: z.number().int().min(1).max(
|
|
272
|
+
limit: z.number().int().min(1).max(MAX_ITEMS_CEILING).optional().describe('Máximo de itens devolvidos (default 25; até FG_TRACKER_MAX_ITEMS)'),
|
|
137
273
|
},
|
|
138
274
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
139
|
-
}, async ({ projectId, range, search, sort, limit }) => guard(async () => {
|
|
140
|
-
const endpoints = await client.get(`/v1/projects/${projectId}/endpoints`, { range, search, sort });
|
|
141
|
-
const
|
|
275
|
+
}, async ({ projectId, range, rootKind, service, env, search, sort, limit }) => guard(async () => {
|
|
276
|
+
const endpoints = await client.get(`/v1/projects/${projectId}/endpoints`, { range, rootKind, service, env, search, sort });
|
|
277
|
+
const requested = limit ?? 25;
|
|
278
|
+
const max = clampLimit(requested, maxItems).limit;
|
|
142
279
|
const shown = endpoints.slice(0, max);
|
|
143
|
-
const
|
|
280
|
+
const noun = rootKind === 'custom' ? 'jobs' : rootKind === 'all' ? 'raízes' : 'endpoints';
|
|
281
|
+
const summary = `${endpoints.length} ${noun} no range ${range}${filterNote({ rootKind, service, env })}${endpoints.length > max ? ` (mostrando ${max})` : ''}.${cappedNote(requested, max)}`;
|
|
144
282
|
return json(shown, summary + (endpoints.length > max ? ` Use limit/search para ver o restante.` : ''));
|
|
145
283
|
}));
|
|
146
|
-
server.registerTool('
|
|
284
|
+
server.registerTool('tracker_list_issues', {
|
|
147
285
|
title: 'Listar issues',
|
|
148
|
-
description: 'Issues detectadas automaticamente (N_PLUS_ONE, LOOP, SLOW_ENDPOINT, UNTRACED_GAP, ERROR) com severidade,
|
|
286
|
+
description: 'Issues detectadas automaticamente (N_PLUS_ONE, LOOP, SLOW_ENDPOINT, UNTRACED_GAP, ERROR), paginadas, com filtros por status, tipo, severidade, texto no título/fingerprint e release. `severity` é a maior já detectada; `lastSeverity` e `lastMetric` descrevem a última detecção (null = desconhecida, issue sem detecção recente registrada). Para ver mais, repita a chamada com o `nextCursor` devolvido.',
|
|
149
287
|
inputSchema: {
|
|
150
288
|
projectId: projectIdSchema,
|
|
151
289
|
status: z.enum(['open', 'resolved', 'ignored', 'all']).default('open').describe('Filtro de status (default open)'),
|
|
290
|
+
sort: z
|
|
291
|
+
.enum(ISSUE_SORTS)
|
|
292
|
+
.default('recent')
|
|
293
|
+
.describe('recent = última ocorrência · impact = escalando, usuários afetados e ocorrências em 24h · count = mais ocorrências · first = mais novas'),
|
|
294
|
+
type: z.array(z.enum(ISSUE_TYPES)).min(1).max(ISSUE_TYPES.length).optional().describe('Um ou mais tipos'),
|
|
295
|
+
severity: z.array(z.enum(ISSUE_SEVERITIES)).min(1).max(ISSUE_SEVERITIES.length).optional().describe('Severidade máxima histórica'),
|
|
296
|
+
q: z.string().trim().min(1).max(200).optional().describe('Texto no título ou na fingerprint, ex.: "media job"'),
|
|
297
|
+
release: z.string().min(1).max(200).optional().describe('Issues vistas nesta release'),
|
|
298
|
+
limit: z.number().int().min(1).max(200).default(50).describe('Itens por página (até 200)'),
|
|
299
|
+
cursor: z.string().max(512).optional().describe('nextCursor da página anterior; use os mesmos filtros e sort'),
|
|
152
300
|
},
|
|
153
301
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
154
|
-
}, async ({ projectId, status }) => guard(async () => {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
302
|
+
}, async ({ projectId, status, sort, type, severity, q, release, limit, cursor }) => guard(async () => {
|
|
303
|
+
const size = clampLimit(limit, maxItems);
|
|
304
|
+
const res = await client.get(`/v1/projects/${projectId}/issues`, {
|
|
305
|
+
status,
|
|
306
|
+
sort,
|
|
307
|
+
type: type?.join(','),
|
|
308
|
+
severity: severity?.join(','),
|
|
309
|
+
q,
|
|
310
|
+
release,
|
|
311
|
+
limit: size.limit,
|
|
312
|
+
cursor,
|
|
313
|
+
});
|
|
314
|
+
// API anterior à paginação: devolve array com tudo e ignora os filtros novos.
|
|
315
|
+
const legacy = Array.isArray(res);
|
|
316
|
+
const page = legacy ? { items: res.slice(0, size.limit), nextCursor: null, total: res.length, counts: null, limit: size.limit } : res;
|
|
317
|
+
const filters = [type?.length ? `tipo ${type.join('|')}` : '', severity?.length ? `severidade ${severity.join('|')}` : '', q ? `"${q}"` : '', release ? `release ${release}` : '']
|
|
318
|
+
.filter(Boolean)
|
|
319
|
+
.join(', ');
|
|
320
|
+
const byStatus = page.counts ? `Por status com esses filtros: ${page.counts.open} abertas · ${page.counts.resolved} resolvidas · ${page.counts.ignored} ignoradas` : '';
|
|
321
|
+
if (page.items.length === 0) {
|
|
322
|
+
return text(`Nenhuma issue com status "${status}"${filters ? ` (${filters})` : ''}${cursor ? ' nesta página' : ''}.${byStatus ? `\n${byStatus}` : ''}`);
|
|
323
|
+
}
|
|
324
|
+
const header = [
|
|
325
|
+
`${page.total} issues (${status}${filters ? `; ${filters}` : ''}; sort ${sort}) · mostrando ${page.items.length}${page.nextCursor ? ' · há mais: repita com cursor = nextCursor' : ''}${cappedNote(limit, size.limit)}`,
|
|
326
|
+
byStatus,
|
|
327
|
+
legacy && (type || severity || q || release || cursor) ? 'Aviso: esta API ainda não pagina nem filtra issues; os filtros e o cursor foram ignorados.' : '',
|
|
328
|
+
legacy && page.total > page.items.length ? `A API não devolve cursor: aumente limit (até 200) para ver mais.` : '',
|
|
329
|
+
].filter(Boolean);
|
|
330
|
+
const lines = page.items.map((i) => `- [${severityLabel(i)}] ${i.type}: ${i.title} (×${i.count}${i.sampleTraceId ? `, trace ${i.sampleTraceId}` : ''}) · id ${i.id}`);
|
|
331
|
+
return json({ ...page, items: page.items.map((i) => compact(i)) }, `${header.join('\n')}\n${lines.join('\n')}`);
|
|
163
332
|
}));
|
|
164
|
-
server.registerTool('
|
|
333
|
+
server.registerTool('tracker_list_traces', {
|
|
165
334
|
title: 'Listar traces',
|
|
166
|
-
description: 'Traces recentes do projeto (para escolher um traceId e chamar
|
|
335
|
+
description: 'Traces recentes do projeto (para escolher um traceId e chamar tracker_get_trace), um item por trace. Filtros: endpoint, status ok|error, duração mínima, release, userId, rootKind (custom = jobs), service e env, todos aplicados à raiz; ' +
|
|
336
|
+
'num trace distribuído a raiz mais antiga que casa com os filtros representa o trace. Paginado pela API: repita com cursor = nextCursor.',
|
|
167
337
|
inputSchema: {
|
|
168
338
|
projectId: projectIdSchema,
|
|
169
339
|
range: rangeSchema,
|
|
170
|
-
|
|
340
|
+
rootKind: rootKindSchema,
|
|
341
|
+
service: serviceFilterSchema,
|
|
342
|
+
env: envFilterSchema,
|
|
343
|
+
endpoint: z.string().optional().describe('Nome do endpoint ou job (ex.: "GET /users/:id", "media job")'),
|
|
171
344
|
status: z.enum(['ok', 'error']).optional(),
|
|
172
345
|
minDurationMs: z.number().min(0).optional(),
|
|
173
|
-
limit: z.number().int().min(1).max(200).default(20),
|
|
346
|
+
limit: z.number().int().min(1).max(200).default(20).describe('Traces por página (default 20, máx. 200; até FG_TRACKER_MAX_ITEMS)'),
|
|
174
347
|
cursor: z.string().optional().describe('nextCursor da página anterior'),
|
|
348
|
+
release: z.string().max(200).optional().describe('Só traces desta release (attributes.release)'),
|
|
349
|
+
userId: z.string().max(200).optional().describe('Só traces deste usuário (attributes.user.id)'),
|
|
175
350
|
},
|
|
176
351
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
177
|
-
}, async ({ projectId, range, endpoint, status, minDurationMs, limit, cursor }) => guard(async () => {
|
|
178
|
-
const
|
|
352
|
+
}, async ({ projectId, range, rootKind, service, env, endpoint, status, minDurationMs, limit, cursor, release, userId }) => guard(async () => {
|
|
353
|
+
const page = clampLimit(limit, maxItems);
|
|
354
|
+
const res = await client.get(`/v1/projects/${projectId}/traces`, { range, rootKind, service, env, endpoint, status, minDurationMs, limit: page.limit, cursor, release, userId });
|
|
179
355
|
if (res.items.length === 0)
|
|
180
|
-
return text(`Nenhum trace no range ${range}${endpoint ? ` para ${endpoint}` : ''}.`);
|
|
181
|
-
const lines = res.items.map((t) => `- ${t.traceId} · ${t.name} · ${t.statusCode ?? '—'} · ${ms(t.durationMs)} · ${t.spanCount} spans/${t.dbQueries} queries · ${ago(t.startedAt)}${t.hasIssue ? ' · ISSUE' : ''}`);
|
|
182
|
-
return json(res, `${res.items.length} traces${res.nextCursor ? ' (há mais: use cursor)' : ''}
|
|
356
|
+
return text(`Nenhum trace no range ${range}${endpoint ? ` para ${endpoint}` : ''}${release ? ` na release ${release}` : ''}${userId ? ` do usuário ${userId}` : ''}${filterNote({ rootKind, service, env })}.`);
|
|
357
|
+
const lines = res.items.map((t) => `- ${t.traceId} · ${t.name}${t.kind && t.kind !== 'http.server' ? ` [${t.kind}]` : ''}${t.service ? ` · ${t.service}` : ''} · ${t.statusCode ?? '—'} · ${ms(t.durationMs)} · ${t.spanCount} spans/${t.dbQueries} queries · ${ago(t.startedAt)}${t.hasIssue ? ' · ISSUE' : ''}`);
|
|
358
|
+
return json(res, `${res.items.length} traces${res.nextCursor ? ' (há mais: use cursor)' : ''}:${cappedNote(limit, page.limit)}\n${lines.join('\n')}`);
|
|
183
359
|
}));
|
|
184
|
-
server.registerTool('
|
|
360
|
+
server.registerTool('tracker_get_trace', {
|
|
185
361
|
title: 'Detalhe de um trace',
|
|
186
|
-
description: 'Waterfall de um trace: spans em pré-ordem com depth/offsetMs, issues ligadas, resumo por kind, spans repetidos (evidência de N+1/loop) e `gaps` — trechos do request que nenhum span explica.
|
|
187
|
-
|
|
362
|
+
description: 'Waterfall de um trace: spans em pré-ordem com depth/offsetMs, issues ligadas, resumo por kind, spans repetidos (evidência de N+1/loop) e `gaps` — trechos do request que nenhum span explica. ' +
|
|
363
|
+
'Spans paginados: até 80 por página (spanLimit, até FG_TRACKER_MAX_ITEMS); `spanPage` traz total, quantos casaram com os filtros e nextSpanOffset para a próxima página. Filtros: kind, service, name (contém). ' +
|
|
364
|
+
'A API devolve o trace inteiro; a paginação é feita pelo MCP sobre essa resposta (spans que chegarem depois mudam as posições).',
|
|
365
|
+
inputSchema: {
|
|
366
|
+
projectId: projectIdSchema,
|
|
367
|
+
traceId: z.string().min(1).describe('traceId (veja tracker_list_traces ou sampleTraceId de uma issue)'),
|
|
368
|
+
spanOffset: z.number().int().min(0).default(0).describe('Posição do primeiro span na lista (pré-ordem, depois dos filtros); use nextSpanOffset'),
|
|
369
|
+
spanLimit: z.number().int().min(1).max(MAX_ITEMS_CEILING).default(MAX_TRACE_SPANS).describe(`Spans por página (default ${MAX_TRACE_SPANS}; até FG_TRACKER_MAX_ITEMS)`),
|
|
370
|
+
kind: z.string().max(64).optional().describe('Só spans deste kind (ex.: db.query, http.client, custom)'),
|
|
371
|
+
service: z.string().max(120).optional().describe('Só spans deste serviço (veja `services`)'),
|
|
372
|
+
name: z.string().max(200).optional().describe('Só spans cujo nome contém este texto (sem diferenciar maiúsculas)'),
|
|
373
|
+
},
|
|
188
374
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
189
|
-
}, async ({ projectId, traceId }) => guard(async () => {
|
|
375
|
+
}, async ({ projectId, traceId, spanOffset, spanLimit, kind, service, name }) => guard(async () => {
|
|
190
376
|
const trace = await client.get(`/v1/projects/${projectId}/traces/${encodeURIComponent(traceId)}`);
|
|
191
377
|
const t = trace;
|
|
378
|
+
const allSpans = Array.isArray(t.spans) ? t.spans : [];
|
|
379
|
+
const needle = name?.toLowerCase();
|
|
380
|
+
const matched = allSpans.filter((span) => (!kind || span.kind === kind) && (!service || span.service === service) && (!needle || (span.name ?? '').toLowerCase().includes(needle)));
|
|
381
|
+
const pageSize = clampLimit(spanLimit, maxItems).limit;
|
|
382
|
+
const spans = matched.slice(spanOffset, spanOffset + pageSize);
|
|
383
|
+
const end = spanOffset + spans.length;
|
|
384
|
+
const filters = Object.fromEntries(Object.entries({ kind, service, name }).filter(([, value]) => value !== undefined));
|
|
385
|
+
const filtered = Object.keys(filters).length > 0;
|
|
386
|
+
const spanPage = {
|
|
387
|
+
total: allSpans.length,
|
|
388
|
+
matched: matched.length,
|
|
389
|
+
offset: spanOffset,
|
|
390
|
+
returned: spans.length,
|
|
391
|
+
nextSpanOffset: end < matched.length ? end : null,
|
|
392
|
+
...(filtered ? { filters } : {}),
|
|
393
|
+
};
|
|
192
394
|
const repeated = t.summary?.repeated?.slice(0, 5).map((r) => `${r.kind} "${r.name}" ×${r.count}`).join('; ');
|
|
193
395
|
// O buraco é a informação que o agente não consegue inferir da lista de spans.
|
|
194
396
|
const untraced = t.summary?.untraced;
|
|
195
397
|
const gapNote = untraced && untraced.totalMs > 0
|
|
196
398
|
? ` · ATENÇÃO: ${ms(untraced.totalMs)} (${Math.round(untraced.ratio * 100)}%) fora de qualquer span${t.gaps?.[0] ? `, depois de "${t.gaps[0].spanName}"` : ''}`
|
|
197
399
|
: '';
|
|
198
|
-
const
|
|
199
|
-
|
|
400
|
+
const filterLabel = Object.entries(filters).map(([key, value]) => `${key}=${value}`).join(', ');
|
|
401
|
+
let pageNote;
|
|
402
|
+
if (matched.length === 0)
|
|
403
|
+
pageNote = filtered ? `Nenhum span casa com ${filterLabel}.` : 'Trace sem spans.';
|
|
404
|
+
else if (spans.length === 0)
|
|
405
|
+
pageNote = `spanOffset ${spanOffset} passa do fim: ${matched.length} spans${filtered ? ` com ${filterLabel}` : ''}.`;
|
|
406
|
+
else {
|
|
407
|
+
pageNote = `Spans ${spanOffset + 1}–${end} de ${matched.length}${filtered ? ` com ${filterLabel} (de ${allSpans.length} no trace)` : ''}.`;
|
|
408
|
+
if (spanPage.nextSpanOffset !== null)
|
|
409
|
+
pageNote += ` Próxima página: spanOffset: ${spanPage.nextSpanOffset}.`;
|
|
410
|
+
}
|
|
411
|
+
pageNote += cappedNote(spanLimit, pageSize);
|
|
412
|
+
const summary = `${t.name ?? traceId}: ${ms(t.durationMs)}, status ${t.statusCode ?? '—'}, ${allSpans.length} spans${repeated ? ` · repetidos: ${repeated}` : ''}${gapNote}\n${pageNote}`;
|
|
413
|
+
const { spans: _all, ...rest } = trace;
|
|
414
|
+
return json({ ...compact(rest), spanPage, spans: spans.map((span) => compact(span)) }, summary);
|
|
200
415
|
}));
|
|
201
|
-
server.registerTool('
|
|
416
|
+
server.registerTool('tracker_search_logs', {
|
|
202
417
|
title: 'Buscar logs',
|
|
203
|
-
description: 'Busca logs do projeto (GET /v1/projects/:id/logs). `q`: texto livre (full-text), "frase" entre aspas, key=value filtra context, ~trecho faz substring. `level` é o nível mínimo. Devolve os mais recentes primeiro.',
|
|
418
|
+
description: 'Busca logs do projeto (GET /v1/projects/:id/logs). `q`: texto livre (full-text), "frase" entre aspas, key=value filtra context, ~trecho faz substring. `level` é o nível mínimo. Filtros exatos: service, traceId, requestId, env. Devolve os mais recentes primeiro; paginado pela API (cursor = nextCursor).',
|
|
204
419
|
inputSchema: {
|
|
205
420
|
projectId: projectIdSchema,
|
|
206
421
|
q: z.string().optional().describe('Consulta textual'),
|
|
207
422
|
service: z.string().optional().describe('Nome do serviço'),
|
|
208
423
|
level: z.enum(LOG_LEVELS).optional().describe('Nível mínimo: trace|debug|info|warn|error|fatal'),
|
|
209
424
|
traceId: z.string().optional(),
|
|
425
|
+
requestId: z.string().max(128).optional().describe('requestId da requisição'),
|
|
426
|
+
env: z.string().max(64).optional().describe('Ambiente (ex.: production)'),
|
|
210
427
|
range: z.enum(RANGE_KEYS).default('1h').describe('Janela (default 1h)'),
|
|
211
|
-
limit: z.number().int().min(1).max(200).default(50),
|
|
212
|
-
cursor: z.string().optional(),
|
|
428
|
+
limit: z.number().int().min(1).max(200).default(50).describe('Logs por página (default 50, máx. 200; até FG_TRACKER_MAX_ITEMS)'),
|
|
429
|
+
cursor: z.string().optional().describe('nextCursor da página anterior'),
|
|
213
430
|
},
|
|
214
431
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
215
|
-
}, async ({ projectId, q, service, level, traceId, range, limit, cursor }) => guard(async () => {
|
|
432
|
+
}, async ({ projectId, q, service, level, traceId, requestId, env, range, limit, cursor }) => guard(async () => {
|
|
216
433
|
const { from, to } = rangeWindow(range);
|
|
434
|
+
const page = clampLimit(limit, maxItems);
|
|
217
435
|
let res;
|
|
218
436
|
try {
|
|
219
|
-
res = await client.get(`/v1/projects/${projectId}/logs`, { from, to, q, service, level, traceId, limit, cursor, order: 'desc' });
|
|
437
|
+
res = await client.get(`/v1/projects/${projectId}/logs`, { from, to, q, service, level, traceId, requestId, env, limit: page.limit, cursor, order: 'desc' });
|
|
220
438
|
}
|
|
221
439
|
catch (err) {
|
|
222
440
|
if (err instanceof TrackApiError && err.status === 404 && /Cannot GET/.test(err.message)) {
|
|
223
|
-
return text(`A API em ${client.baseUrl} ainda não expõe GET /v1/projects/:id/logs (endpoint de logs não disponível nesta versão). Atualize a API do FG
|
|
441
|
+
return text(`A API em ${client.baseUrl} ainda não expõe GET /v1/projects/:id/logs (endpoint de logs não disponível nesta versão). Atualize a API do FG Tracker.`);
|
|
224
442
|
}
|
|
225
443
|
throw err;
|
|
226
444
|
}
|
|
227
445
|
if (res.items.length === 0)
|
|
228
|
-
return text(`Nenhum log no range ${range}${q ? ` para "${q}"` : ''}${service ? ` (service ${service})` : ''}.`);
|
|
229
|
-
const lines = res.items.
|
|
230
|
-
return json(compact(
|
|
446
|
+
return text(`Nenhum log no range ${range}${q ? ` para "${q}"` : ''}${service ? ` (service ${service})` : ''}${env ? ` (env ${env})` : ''}${requestId ? ` (requestId ${requestId})` : ''}.`);
|
|
447
|
+
const lines = res.items.map((l) => `${l.ts} [${l.level}] ${l.service}: ${l.message.slice(0, 160)}${l.traceId ? ` (trace ${l.traceId})` : ''}`);
|
|
448
|
+
return json({ ...res, items: res.items.map((item) => compact(item)) }, `${res.items.length} logs${res.nextCursor ? ' (há mais: use cursor)' : ''}:${cappedNote(limit, page.limit)}\n${preview(lines)}`);
|
|
231
449
|
}));
|
|
232
|
-
server.registerTool('
|
|
450
|
+
server.registerTool('tracker_instrumentation_guide', {
|
|
233
451
|
title: 'Guia de instrumentação',
|
|
234
|
-
description: 'Passo a passo com código copiável para conectar um app ao FG
|
|
452
|
+
description: 'Passo a passo com código copiável para conectar um app ao FG Tracker: express, nestjs, nextjs, fastify (via hook), pino ou winston (logs). Com projectId, o código já vem com a apiKey real do projeto.',
|
|
235
453
|
inputSchema: {
|
|
236
454
|
framework: z.enum(FRAMEWORKS).describe('express | nestjs | nextjs | fastify | pino | winston'),
|
|
237
455
|
projectId: z.string().uuid().optional().describe('Projeto para usar a chave real (recomendado)'),
|
|
238
456
|
serviceName: z.string().optional().describe('serviceName do tracker (default: slug do projeto)'),
|
|
239
|
-
databaseDriver: z.string().optional().describe('Driver returned by
|
|
457
|
+
databaseDriver: z.string().optional().describe('Driver returned by tracker_detect_databases; never assume pg for other clients'),
|
|
240
458
|
},
|
|
241
459
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
242
460
|
}, async ({ framework, projectId, serviceName, databaseDriver }) => guard(async () => {
|
|
@@ -252,9 +470,9 @@ export function createServer(options = {}) {
|
|
|
252
470
|
projectId: project?.id,
|
|
253
471
|
serviceName: serviceName ?? project?.slug,
|
|
254
472
|
});
|
|
255
|
-
return text(guide + '\n\n' + (databaseDriver ? databaseGuide(databaseDriver, serviceName ?? project?.slug ?? 'app') : 'Before instrumenting any database call
|
|
473
|
+
return text(guide + '\n\n' + (databaseDriver ? databaseGuide(databaseDriver, serviceName ?? project?.slug ?? 'app') : 'Before instrumenting any database call tracker_detect_databases with the local project path. The pg examples only apply to pg.Pool, never to Postgres.js/Prisma/other drivers.'));
|
|
256
474
|
}));
|
|
257
|
-
server.registerTool('
|
|
475
|
+
server.registerTool('tracker_verify_ingest', {
|
|
258
476
|
title: 'Verificar ingestão',
|
|
259
477
|
description: 'Confirma se spans e logs do projeto chegaram recentemente (últimos 15 min): consulta o trace e o log mais recentes e explica o que fazer se nada chegou.',
|
|
260
478
|
inputSchema: { projectId: projectIdSchema },
|
|
@@ -297,18 +515,18 @@ export function createServer(options = {}) {
|
|
|
297
515
|
}
|
|
298
516
|
lines.push('');
|
|
299
517
|
if (spansOk) {
|
|
300
|
-
lines.push(`Veredito: ingestão de traces funcionando${logsOk ? ' e logs chegando' : logsAvailable ? '; logs ainda não (opcional)' : ''}. Explore com
|
|
518
|
+
lines.push(`Veredito: ingestão de traces funcionando${logsOk ? ' e logs chegando' : logsAvailable ? '; logs ainda não (opcional)' : ''}. Explore com tracker_get_overview { projectId: "${projectId}", range: "15m" } e tracker_list_issues.`);
|
|
301
519
|
}
|
|
302
520
|
else {
|
|
303
|
-
lines.push('Veredito: nada chegou. Checklist:', `1. O app está rodando e recebeu requisições nos últimos minutos? (o SDK envia a cada 2 s; chame tracker.flush() para forçar)`, `2. FG_TRACKER_KEY é a chave deste projeto? (
|
|
521
|
+
lines.push('Veredito: nada chegou. Checklist:', `1. O app está rodando e recebeu requisições nos últimos minutos? (o SDK envia a cada 2 s; chame tracker.flush() para forçar)`, `2. FG_TRACKER_KEY é a chave deste projeto? (tracker_list_projects mostra a apiKey; resposta 4xx no envio = chave errada, o SDK avisa uma vez no console)`, `3. O app alcança a API? (curl ${client.baseUrl}/v1/health de dentro do ambiente do app; endpoint do tracker = FG_TRACKER_URL)`, '4. app.use(tracker.express()) é o PRIMEIRO middleware / wrapNextRoute envolve o handler / hook onRequest registrado antes das rotas?', '5. createTracker recebeu enabled: false ou apiKey vazia? (tracker vira no-op com aviso "[fg-tracker] apiKey ausente")', `Teste direto sem o SDK: curl -X POST ${client.baseUrl}/v1/ingest -H 'x-fg-tracker-key: <apiKey>' -H 'content-type: application/json' -d '{"traces":[...]}' (formato em ${QUICKSTART_URI}).`);
|
|
304
522
|
}
|
|
305
523
|
return text(lines.join('\n'));
|
|
306
524
|
}));
|
|
307
525
|
/* ---------------- releases, issues, monitores, provedores (CONTRATO.md §15–§20) ---------------- */
|
|
308
|
-
server.registerTool('
|
|
526
|
+
server.registerTool('tracker_get_issue', {
|
|
309
527
|
title: 'Detalhe de uma issue',
|
|
310
528
|
description: 'Issue estilo Sentry: ocorrências recentes (timestamp, trace, usuário, release), ocorrências por dia, usuários mais afetados, releases em que apareceu, regressões (resolvida que voltou) e commits/PRs suspeitos (frames do stack cruzados com os arquivos alterados nas releases).',
|
|
311
|
-
inputSchema: { projectId: projectIdSchema, issueId: z.string().uuid().describe('id da issue (
|
|
529
|
+
inputSchema: { projectId: projectIdSchema, issueId: z.string().uuid().describe('id da issue (tracker_list_issues)') },
|
|
312
530
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
313
531
|
}, async ({ projectId, issueId }) => guard(async () => {
|
|
314
532
|
const issue = await client.get(`/v1/projects/${projectId}/issues/${issueId}`);
|
|
@@ -317,24 +535,39 @@ export function createServer(options = {}) {
|
|
|
317
535
|
const summary = `${i.title}: ×${i.count ?? 0}, ${i.impact?.usersAffected ?? 0} usuários, ${i.impact?.occurrences24h ?? 0} em 24h${i.firstRelease ? ` · desde ${i.firstRelease}` : ''}${i.regressedRelease ? ` · regressão em ${i.regressedRelease}` : ''}${suspects ? `\nCommits suspeitos:\n${suspects}` : ''}`;
|
|
318
536
|
return json(compact(issue), summary);
|
|
319
537
|
}));
|
|
320
|
-
server.registerTool('
|
|
538
|
+
server.registerTool('tracker_list_releases', {
|
|
321
539
|
title: 'Releases e saúde por versão',
|
|
322
|
-
description: 'Releases/deploys do projeto com requests, taxa de erro, p95, issues novas, regressões e variação vs. a release anterior. Com `version`, devolve o detalhe: commits, comparação de endpoints com a anterior e issues.',
|
|
323
|
-
inputSchema: {
|
|
540
|
+
description: 'Releases/deploys do projeto com requests, taxa de erro, p95, issues novas, regressões, ambientes e variação vs. a release anterior (mais recentes primeiro; a API devolve até 100, sem cursor). Com `version`, devolve o detalhe: commits, comparação de endpoints com a anterior e issues.',
|
|
541
|
+
inputSchema: {
|
|
542
|
+
projectId: projectIdSchema,
|
|
543
|
+
version: z.string().optional().describe('Versão exata para o detalhe'),
|
|
544
|
+
environment: z.string().max(64).optional().describe('Só releases vistas/registradas neste ambiente, com saúde calculada nele'),
|
|
545
|
+
limit: z.number().int().min(1).max(100).default(30).describe('Máximo de releases (default 30, máx. 100; até FG_TRACKER_MAX_ITEMS)'),
|
|
546
|
+
},
|
|
324
547
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
325
|
-
}, async ({ projectId, version }) => guard(async () => {
|
|
548
|
+
}, async ({ projectId, version, environment, limit }) => guard(async () => {
|
|
326
549
|
if (version) {
|
|
327
|
-
const detail = await client.get(`/v1/projects/${projectId}/releases/${encodeURIComponent(version)}
|
|
550
|
+
const detail = await client.get(`/v1/projects/${projectId}/releases/${encodeURIComponent(version)}`, { environment });
|
|
328
551
|
const d = detail;
|
|
329
|
-
|
|
552
|
+
const envNote = environment && d.environments === undefined ? '\nATENÇÃO: esta API não filtra releases por ambiente; a saúde acima inclui todos os ambientes.' : '';
|
|
553
|
+
return json(compact(detail), `${version}${environment ? ` (${environment})` : ''}: ${d.health?.requests ?? 0} req · erro ${pct(d.health?.errorRate)} · p95 ${ms(d.health?.p95Ms)} · ${d.health?.newIssues ?? 0} issues novas · ${d.health?.regressions ?? 0} regressões · ${d.commits?.length ?? 0} commits${envNote}`);
|
|
330
554
|
}
|
|
331
|
-
const
|
|
555
|
+
const page = clampLimit(limit, maxItems);
|
|
556
|
+
const releases = await client.get(`/v1/projects/${projectId}/releases`, { limit: page.limit, environment });
|
|
332
557
|
if (releases.length === 0)
|
|
333
|
-
return text('Nenhuma release. Configure `release` no SDK ou registre deploys com
|
|
334
|
-
const
|
|
335
|
-
|
|
558
|
+
return text(environment ? `Nenhuma release no ambiente ${environment}.` : 'Nenhuma release. Configure `release` no SDK ou registre deploys com tracker_register_release / fg-tracker-release.');
|
|
559
|
+
const envs = (r) => {
|
|
560
|
+
const names = r.environments ? r.environments.map((e) => e.environment) : r.environment ? [r.environment] : [];
|
|
561
|
+
return names.length ? ` · [${names.join(', ')}]` : '';
|
|
562
|
+
};
|
|
563
|
+
const lines = releases.map((r) => `- ${r.version}${r.isCurrent ? ' (atual)' : ''}${envs(r)} · ${ago(r.deployedAt ?? r.firstSeen ?? new Date().toISOString())} · ${r.health.requests} req · erro ${pct(r.health.errorRate)} · p95 ${ms(r.health.p95Ms)}${r.delta.p95Ms !== null ? ` (${r.delta.p95Ms >= 0 ? '+' : ''}${Math.round(r.delta.p95Ms * 100)}%)` : ''} · ${r.health.newIssues} novas · ${r.health.regressions} regressões`);
|
|
564
|
+
const notes = [
|
|
565
|
+
environment && !releases.some((r) => r.environments !== undefined) ? `ATENÇÃO: esta API não filtra releases por ambiente; a lista e a saúde abaixo incluem todos os ambientes.` : '',
|
|
566
|
+
releases.length >= page.limit ? `Limite de ${page.limit} atingido: pode haver releases mais antigas${page.limit < 100 ? ' (aumente limit até 100)' : ' (a API devolve no máximo 100)'}.${cappedNote(limit, page.limit)}` : '',
|
|
567
|
+
].filter(Boolean);
|
|
568
|
+
return json(releases.map((r) => compact(r)), `${releases.length} releases${environment ? ` (${environment})` : ''}:\n${preview(lines)}${notes.length ? `\n${notes.join('\n')}` : ''}`);
|
|
336
569
|
}));
|
|
337
|
-
server.registerTool('
|
|
570
|
+
server.registerTool('tracker_register_release', {
|
|
338
571
|
title: 'Registrar release/deploy',
|
|
339
572
|
description: 'Registra uma release no projeto (POST /v1/projects/:id/releases) com versão, SHA, repositório e commits opcionais. Para enviar commits e arquivos alterados a partir do git, prefira o CLI `fg-tracker-release` (pacote fg-tracker-sdk) no passo de deploy.',
|
|
340
573
|
inputSchema: {
|
|
@@ -352,7 +585,7 @@ export function createServer(options = {}) {
|
|
|
352
585
|
const release = await client.post(`/v1/projects/${projectId}/releases`, body);
|
|
353
586
|
return json(compact(release), `Release ${body.version} registrada.`);
|
|
354
587
|
}));
|
|
355
|
-
server.registerTool('
|
|
588
|
+
server.registerTool('tracker_endpoint_history', {
|
|
356
589
|
title: 'Histórico de latência de uma rota',
|
|
357
590
|
description: 'Rollups horários (até 400 dias) de uma rota: série de média/p50/p95/p99, comparação da janela atual com a anterior e média/p95 por release — para saber se as mudanças melhoraram ou pioraram o tempo de resposta.',
|
|
358
591
|
inputSchema: { projectId: projectIdSchema, name: z.string().min(1).describe('Nome exato do endpoint (ex.: "GET /users/:id")'), days: z.number().int().min(1).max(400).default(30) },
|
|
@@ -364,7 +597,7 @@ export function createServer(options = {}) {
|
|
|
364
597
|
const byRelease = (h.releases ?? []).map((r) => `- ${r.release}: média ${ms(r.avgMs)} · p95 ${ms(r.p95Ms)} (${fmt(r.deltaP95)} vs. anterior)`).join('\n');
|
|
365
598
|
return json(compact(history), `${name} (${days}d): média ${ms(h.current?.avgMs)} (${fmt(h.delta?.avgMs)}), p95 ${ms(h.current?.p95Ms)} (${fmt(h.delta?.p95Ms)}) vs. janela anterior (média ${ms(h.previous?.avgMs)}, p95 ${ms(h.previous?.p95Ms)}).${byRelease ? `\nPor release:\n${byRelease}` : ''}`);
|
|
366
599
|
}));
|
|
367
|
-
server.registerTool('
|
|
600
|
+
server.registerTool('tracker_list_providers', {
|
|
368
601
|
title: 'Provedores externos (chamadas de saída)',
|
|
369
602
|
description: 'Chamadas http.client agregadas por host no range (OpenRouter, Mureka, Stripe…): requests, taxa de erro, p50/p95, tempo total, operações mais chamadas e endpoints que as disparam.',
|
|
370
603
|
inputSchema: { projectId: projectIdSchema, range: rangeSchema },
|
|
@@ -376,7 +609,7 @@ export function createServer(options = {}) {
|
|
|
376
609
|
const lines = providers.items.slice(0, 25).map((p) => `- ${p.host}: ${p.requests} chamadas · erro ${pct(p.errorRate)} · p95 ${ms(p.p95Ms)} · total ${ms(p.totalMs)}`);
|
|
377
610
|
return json(compact(providers), `${providers.items.length} hosts, ${providers.totalRequests} chamadas, erro ${pct(providers.errorRate)}:\n${lines.join('\n')}`);
|
|
378
611
|
}));
|
|
379
|
-
server.registerTool('
|
|
612
|
+
server.registerTool('tracker_traffic_summary', {
|
|
380
613
|
title: 'Tráfego (pageviews) do projeto',
|
|
381
614
|
description: 'Visitantes únicos, sessões, pageviews, bounce e duração média por dia (UTC) vindos do SDK de navegador com analytics: true, mais top páginas, referrers, UTM, dispositivo e país. Rollup diário atualizado de hora em hora.',
|
|
382
615
|
inputSchema: { projectId: projectIdSchema, days: z.number().int().min(1).max(400).default(30).describe('Últimos N dias, incluindo hoje') },
|
|
@@ -386,8 +619,48 @@ export function createServer(options = {}) {
|
|
|
386
619
|
const day = (offset) => new Date(now - offset * 86_400_000).toISOString().slice(0, 10);
|
|
387
620
|
const traffic = await client.get(`/v1/projects/${projectId}/traffic`, { from: day(days - 1), to: day(0), limit: 5 });
|
|
388
621
|
const t = traffic.totals;
|
|
389
|
-
if (t.pageviews === 0)
|
|
390
|
-
|
|
622
|
+
if (t.pageviews === 0) {
|
|
623
|
+
// Janela vazia não quer dizer analytics desligado: o dia UTC pode não ter
|
|
624
|
+
// passado pelo rollup ainda, ou os dados estão antes da janela.
|
|
625
|
+
const TRAFFIC_MAX_DAYS = 400; // maior janela de GET /v1/projects/:id/traffic
|
|
626
|
+
const from = day(days - 1);
|
|
627
|
+
const lines = [`Nenhum pageview de ${from} a ${day(0)} (dias UTC).`];
|
|
628
|
+
const rollupAt = traffic.lastRollupAt;
|
|
629
|
+
// Sem rollup em dia, janela e histórico vazios não provam que nada chegou.
|
|
630
|
+
const rollupPending = !rollupAt || Date.parse(rollupAt) < Date.parse(`${from}T00:00:00Z`);
|
|
631
|
+
if (!rollupAt)
|
|
632
|
+
lines.push('O rollup de tráfego ainda não rodou nesta API (roda de hora em hora); pageviews recebidos só aparecem depois dele.');
|
|
633
|
+
else if (Date.parse(rollupAt) < Date.parse(`${from}T00:00:00Z`))
|
|
634
|
+
lines.push(`O último rollup foi ${ago(rollupAt, now)} (${rollupAt}), antes do início da janela: os pageviews desses dias (UTC) ainda não foram processados. O rollup roda de hora em hora.`);
|
|
635
|
+
else
|
|
636
|
+
lines.push(`Último rollup ${ago(rollupAt, now)}.`);
|
|
637
|
+
let lastDay;
|
|
638
|
+
let historyChecked = false;
|
|
639
|
+
if (days < TRAFFIC_MAX_DAYS) {
|
|
640
|
+
try {
|
|
641
|
+
const history = await client.get(`/v1/projects/${projectId}/traffic`, { from: day(TRAFFIC_MAX_DAYS - 1), to: day(days), dims: 'path', limit: 1 });
|
|
642
|
+
historyChecked = true;
|
|
643
|
+
lastDay = [...(history.days ?? [])].reverse().find((d) => d.pageviews > 0);
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
/* sem histórico: cai na mensagem genérica */
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (lastDay) {
|
|
650
|
+
const span = Math.round((Date.parse(day(0)) - Date.parse(lastDay.day)) / 86_400_000) + 1;
|
|
651
|
+
lines.push(`Analytics já enviou dados: o último dia com pageviews é ${lastDay.day} (${lastDay.pageviews}). Amplie a janela, ex.: days: ${span}.`);
|
|
652
|
+
}
|
|
653
|
+
else if (rollupPending) {
|
|
654
|
+
lines.push('Nenhum pageview processado no histórico, mas com o rollup pendente isso não prova que nada chegou. Consulte de novo depois do próximo rollup antes de concluir que analytics está desligado.');
|
|
655
|
+
}
|
|
656
|
+
else if (historyChecked || days >= TRAFFIC_MAX_DAYS) {
|
|
657
|
+
lines.push(`Nenhum pageview processado nos últimos ${TRAFFIC_MAX_DAYS} dias até o último rollup. Se o site começou a enviar depois dele, aguarde o próximo; senão, ligue analytics: true no fg-tracker-browser (docs/instalar-pageviews.md) e confira se o domínio do site está nos domínios permitidos (tracker_list_projects).`);
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
lines.push(`Se o site nunca enviou pageviews, ligue analytics: true no fg-tracker-browser (docs/instalar-pageviews.md); se já enviou, amplie a janela com days.`);
|
|
661
|
+
}
|
|
662
|
+
return text(lines.join('\n'));
|
|
663
|
+
}
|
|
391
664
|
const top = (dim, label) => {
|
|
392
665
|
const items = traffic.dims[dim] ?? [];
|
|
393
666
|
return items.length ? `${label}: ${items.map((i) => `${i.value} (${i.pageviews})`).join(', ')}` : '';
|
|
@@ -395,17 +668,87 @@ export function createServer(options = {}) {
|
|
|
395
668
|
const lines = [top('path', 'Páginas'), top('referrer', 'Referrers'), top('utm_source', 'UTM source'), top('utm_campaign', 'Campanhas')].filter(Boolean);
|
|
396
669
|
return json(compact(traffic), `Últimos ${days} dias: ${t.visitors} visitantes (soma diária), ${t.sessions} sessões, ${t.pageviews} pageviews, bounce ${pct(t.bounceRate)}, ${t.pagesPerSession} páginas/sessão, sessão média ${Math.round(t.avgSessionSeconds)} s.${traffic.lastRollupAt ? ` Último rollup ${ago(traffic.lastRollupAt)}.` : ''}\n${lines.join('\n')}`);
|
|
397
670
|
}));
|
|
398
|
-
server.registerTool('
|
|
671
|
+
server.registerTool('tracker_list_monitors', {
|
|
399
672
|
title: 'Monitores (uptime e jobs)',
|
|
400
|
-
description: 'Monitores de uptime HTTP e check-ins de jobs agendados: status (ok, down, missed, pending, paused), uptime 30d, último erro e
|
|
401
|
-
inputSchema: {
|
|
673
|
+
description: 'Monitores de uptime HTTP e check-ins de jobs agendados: id, ambiente, status (ok, down, missed, pending, paused), uptime 30d, último erro, próximo prazo e o trace da última execução que reportou um. Histórico de checks: tracker_get_monitor.',
|
|
674
|
+
inputSchema: {
|
|
675
|
+
projectId: projectIdSchema,
|
|
676
|
+
environment: z.string().max(64).optional().describe('Só monitores deste ambiente (API com ambientes)'),
|
|
677
|
+
},
|
|
402
678
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
403
|
-
}, async ({ projectId }) => guard(async () => {
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
679
|
+
}, async ({ projectId, environment }) => guard(async () => {
|
|
680
|
+
const all = await client.get(`/v1/projects/${projectId}/monitors`, { environment });
|
|
681
|
+
const envSupported = all.some((m) => 'environment' in m);
|
|
682
|
+
const monitors = environment && envSupported ? all.filter((m) => m.environment === environment) : all;
|
|
683
|
+
if (monitors.length === 0) {
|
|
684
|
+
return text(environment ? `Nenhum monitor no ambiente ${environment}.` : 'Nenhum monitor. Crie um uptime no painel ou envie um check-in: tracker.checkIn(slug, fn) / GET /v1/monitors/:slug/ping?status=ok.');
|
|
685
|
+
}
|
|
686
|
+
const items = monitors.map(({ recent, ...m }) => {
|
|
687
|
+
const traced = lastTraced(recent);
|
|
688
|
+
return { ...m, lastTraceId: traced?.traceId ?? null, lastTraceAt: traced?.at ?? null, lastRelease: traced?.release ?? null };
|
|
689
|
+
});
|
|
690
|
+
const page = listPage(items, maxItems);
|
|
691
|
+
const lines = items.slice(0, maxItems).map((m) => `- ${monitorLabel(m)} · id ${m.id}${m.superseded ? ' · substituído pelos monitores por ambiente' : ''}${m.uptime30d !== null ? ` · uptime ${pct(m.uptime30d)}` : ''}${m.lastCheckAt ? ` · último check ${ago(m.lastCheckAt)}` : ''}${m.lastTraceId ? ` · último trace ${m.lastTraceId}` : ''}${m.lastError ? ` · ${m.lastError}` : ''}`);
|
|
692
|
+
const notes = [
|
|
693
|
+
environment && !envSupported ? 'ATENÇÃO: esta API não separa monitores por ambiente; o filtro environment não foi aplicado.' : '',
|
|
694
|
+
page.truncated ? `Mostrando ${page.returned} de ${page.total} monitores (FG_TRACKER_MAX_ITEMS=${maxItems}).` : '',
|
|
695
|
+
].filter(Boolean);
|
|
696
|
+
return json(page, `${monitors.length} monitores:\n${preview(lines)}${notes.length ? `\n${notes.join('\n')}` : ''}`);
|
|
697
|
+
}));
|
|
698
|
+
server.registerTool('tracker_get_monitor', {
|
|
699
|
+
title: 'Detalhe de um monitor (histórico de checks)',
|
|
700
|
+
description: 'Histórico de checks de um monitor, mais recentes primeiro: status, duração, status HTTP, erro, origem e, nas execuções de jobs, o traceId (abra com tracker_get_trace) e a release. ' +
|
|
701
|
+
'Informe monitorId (tracker_list_monitors) ou slug; o slug só é aceito quando identifica um único monitor do projeto (use environment para desambiguar).',
|
|
702
|
+
inputSchema: {
|
|
703
|
+
projectId: projectIdSchema,
|
|
704
|
+
monitorId: z.string().uuid().optional().describe('id do monitor (tracker_list_monitors)'),
|
|
705
|
+
slug: z.string().min(1).max(120).optional().describe('slug do monitor, alternativa a monitorId'),
|
|
706
|
+
environment: z.string().max(64).optional().describe('Ambiente do monitor, para desambiguar um slug usado em mais de um'),
|
|
707
|
+
limit: z.number().int().min(1).max(500).default(50).describe('Checks do histórico (default 50, máx. 500; até FG_TRACKER_MAX_ITEMS)'),
|
|
708
|
+
},
|
|
709
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
710
|
+
}, async ({ projectId, monitorId, slug, environment, limit }) => guard(async () => {
|
|
711
|
+
if (Boolean(monitorId) === Boolean(slug))
|
|
712
|
+
throw new Error('Informe monitorId ou slug (só um dos dois).');
|
|
713
|
+
let id = monitorId;
|
|
714
|
+
if (!id) {
|
|
715
|
+
const monitors = await client.get(`/v1/projects/${projectId}/monitors`);
|
|
716
|
+
let matches = monitors.filter((m) => m.slug === slug);
|
|
717
|
+
if (environment && matches.some((m) => 'environment' in m))
|
|
718
|
+
matches = matches.filter((m) => m.environment === environment);
|
|
719
|
+
if (matches.length === 0) {
|
|
720
|
+
const slugs = [...new Set(monitors.map((m) => m.slug))];
|
|
721
|
+
throw new Error(`Nenhum monitor com slug "${slug}"${environment ? ` no ambiente ${environment}` : ''} neste projeto.${slugs.length ? ` Slugs: ${slugs.slice(0, 25).join(', ')}${slugs.length > 25 ? ` (+${slugs.length - 25})` : ''}.` : ''}`);
|
|
722
|
+
}
|
|
723
|
+
if (matches.length > 1) {
|
|
724
|
+
throw new Error(`O slug "${slug}" identifica ${matches.length} monitores; repita com monitorId${matches.some((m) => 'environment' in m) ? ' ou environment' : ''}:\n` +
|
|
725
|
+
matches.map((m) => `- ${m.id} · ${monitorLabel(m)}`).join('\n'));
|
|
726
|
+
}
|
|
727
|
+
id = matches[0].id;
|
|
728
|
+
}
|
|
729
|
+
const page = clampLimit(limit, maxItems);
|
|
730
|
+
const detail = await client.get(`/v1/projects/${projectId}/monitors/${id}`, { limit: page.limit });
|
|
731
|
+
const { recent: _recent, checks = [], ...monitor } = detail;
|
|
732
|
+
let envNote = '';
|
|
733
|
+
if (environment) {
|
|
734
|
+
// Sem ambiente na resposta (API antiga), o histórico pode misturar ambientes.
|
|
735
|
+
if (!('environment' in detail))
|
|
736
|
+
envNote = '\nATENÇÃO: esta API não separa monitores por ambiente; o filtro environment não foi aplicado e o histórico pode misturar ambientes.';
|
|
737
|
+
else if (detail.environment !== environment)
|
|
738
|
+
throw new Error(`O monitor ${detail.id} é do ambiente ${detail.environment ?? '(sem ambiente)'}, não de ${environment}.`);
|
|
739
|
+
}
|
|
740
|
+
const head = [
|
|
741
|
+
`${monitorLabel(detail)} · id ${detail.id}`,
|
|
742
|
+
detail.uptime30d !== null ? `uptime 30d ${pct(detail.uptime30d)}` : '',
|
|
743
|
+
detail.lastCheckAt ? `último check ${ago(detail.lastCheckAt)}` : '',
|
|
744
|
+
detail.runningSince ? `rodando desde ${detail.runningSince}` : '',
|
|
745
|
+
detail.nextDueAt ? `próximo prazo ${until(detail.nextDueAt)}` : '',
|
|
746
|
+
detail.lastError ? `último erro: ${detail.lastError}` : '',
|
|
747
|
+
].filter(Boolean).join(' · ');
|
|
748
|
+
const lines = checks.map((c) => `- ${c.at} ${c.status}${c.durationMs !== null ? ` · ${ms(c.durationMs)}` : ''}${c.statusCode !== null ? ` · HTTP ${c.statusCode}` : ''}${c.traceId ? ` · trace ${c.traceId}` : ''}${c.release ? ` · release ${c.release}` : ''}${c.error ? ` · ${c.error.slice(0, 160)}` : ''}`);
|
|
749
|
+
const more = checks.length >= page.limit ? `\nLimite de ${page.limit} checks atingido: pode haver checks mais antigos${page.limit < 500 ? ' (aumente limit até 500)' : ' (a API devolve no máximo 500)'}.${cappedNote(limit, page.limit)}` : '';
|
|
750
|
+
const history = checks.length ? `${checks.length} checks (mais recentes primeiro):\n${preview(lines)}${more}` : 'Nenhum check registrado.';
|
|
751
|
+
return json({ ...compact(monitor), checks: checks.map((c) => compact(c)) }, `${head}\n${history}${envNote}`);
|
|
409
752
|
}));
|
|
410
753
|
/* ---------------- infraestrutura (CONTRATO.md §27.9) ---------------- */
|
|
411
754
|
registerInfraTools(server, client, SERVER_VERSION);
|
|
@@ -413,10 +756,10 @@ export function createServer(options = {}) {
|
|
|
413
756
|
registerLoadtestTools(server, client);
|
|
414
757
|
/* ---------------- prompt ---------------- */
|
|
415
758
|
server.registerPrompt('instrument-project', {
|
|
416
|
-
title: 'Conectar projeto ao FG
|
|
417
|
-
description: 'Plano passo a passo para um agente de código instrumentar o projeto atual: detectar framework/logger → criar/escolher projeto → instalar SDK → tracker + pg + fetch + logs → env → rodar →
|
|
759
|
+
title: 'Conectar projeto ao FG Tracker',
|
|
760
|
+
description: 'Plano passo a passo para um agente de código instrumentar o projeto atual: detectar framework/logger → criar/escolher projeto → instalar SDK → tracker + pg + fetch + logs → env → rodar → tracker_verify_ingest → corrigir.',
|
|
418
761
|
argsSchema: {
|
|
419
|
-
projectName: z.string().optional().describe('Nome do projeto no FG
|
|
762
|
+
projectName: z.string().optional().describe('Nome do projeto no FG Tracker (default: nome do pacote)'),
|
|
420
763
|
framework: z.string().optional().describe('express | nestjs | nextjs | fastify (default: detectar)'),
|
|
421
764
|
},
|
|
422
765
|
}, ({ projectName, framework }) => ({
|
|
@@ -429,7 +772,7 @@ export function createServer(options = {}) {
|
|
|
429
772
|
}));
|
|
430
773
|
/* ---------------- resource ---------------- */
|
|
431
774
|
server.registerResource('quickstart', QUICKSTART_URI, {
|
|
432
|
-
title: 'FG
|
|
775
|
+
title: 'FG Tracker quickstart',
|
|
433
776
|
description: 'Guia rápido (markdown): projeto e chave, SDK, logs, ingestão crua, verificação e detectores.',
|
|
434
777
|
mimeType: 'text/markdown',
|
|
435
778
|
}, async (uri) => ({
|
|
@@ -437,7 +780,7 @@ export function createServer(options = {}) {
|
|
|
437
780
|
}));
|
|
438
781
|
// URI antiga (stread-track-mcp ≤ 0.3): mesmo conteúdo, mantida até a 1.0.
|
|
439
782
|
server.registerResource('quickstart-legacy', LEGACY_QUICKSTART_URI, {
|
|
440
|
-
title: 'FG
|
|
783
|
+
title: 'FG Tracker quickstart (URI obsoleta)',
|
|
441
784
|
description: `Obsoleto: use ${QUICKSTART_URI}. Mesmo conteúdo do quickstart.`,
|
|
442
785
|
mimeType: 'text/markdown',
|
|
443
786
|
}, async (uri) => ({
|