diff --git a/src/core/response-builder.ts b/src/core/response-builder.ts index 1d3f193..a1cf175 100644 --- a/src/core/response-builder.ts +++ b/src/core/response-builder.ts @@ -24,4 +24,11 @@ export class ResponseBuilder { content: [{ type: "text", text: content }], }; } + + public static json(data: any): ToolResponse { + const jsonString = JSON.stringify(data, null, 2); + return { + content: [{ type: "text", text: `\`\`\`json\n${jsonString}\n\`\`\`` }], + }; + } } diff --git a/src/lib/application-inspector/application-inspector.client.test.ts b/src/lib/application-inspector/application-inspector.client.test.ts new file mode 100644 index 0000000..fd6e7bd --- /dev/null +++ b/src/lib/application-inspector/application-inspector.client.test.ts @@ -0,0 +1,82 @@ +import { ApplicationInspectorApiClient } from "./application-inspector.client"; + +jest.mock("../../core/http-client", () => { + return { + httpClient: { + request: jest.fn(), + }, + HttpError: class HttpError extends Error { + status: number; + statusText: string; + body: string; + constructor(status: number, statusText: string, body: string, message: string) { + super(message); + this.status = status; + this.statusText = statusText; + this.body = body; + } + }, + }; +}); + +const { httpClient } = require("../../core/http-client"); + +describe("ApplicationInspectorApiClient", () => { + beforeEach(() => { + (httpClient.request as jest.Mock).mockReset(); + }); + + test("getSpans constructs query params from filters including errors_only (maps to status_code=2)", async () => { + const client = new ApplicationInspectorApiClient(); + (httpClient.request as jest.Mock).mockResolvedValueOnce({ spans: [], next_token: null }); + + await client.getSpans({ + limit: 50, + pagination_token: "abc", + service_name: "s3", + operation_name: "PutObject", + trace_id: "t1", + errors_only: true, + region: "us-east-1", + }); + + expect(httpClient.request).toHaveBeenCalledTimes(1); + const [url, options] = (httpClient.request as jest.Mock).mock.calls[0]; + expect(String(url)).toContain("/_localstack/eventstudio/v1/spans?"); + expect(String(url)).toContain("limit=50"); + expect(String(url)).toContain("pagination_token=abc"); + expect(String(url)).toContain("service_name=s3"); + expect(String(url)).toContain("operation_name=PutObject"); + expect(String(url)).toContain("trace_id=t1"); + expect(String(url)).toContain("region=us-east-1"); + // errors_only => status_code=2 + expect(String(url)).toContain("status_code=2"); + expect(options.method).toBe("GET"); + }); + + test("clearEvents sends DELETE with body when spanIds provided", async () => { + const client = new ApplicationInspectorApiClient(); + (httpClient.request as jest.Mock).mockResolvedValueOnce({ deleted_count: 2 }); + + const res = await client.clearEvents(["a", "b"]); + expect(res.deleted_count).toBe(2); + + expect(httpClient.request).toHaveBeenCalledTimes(1); + const [url, options] = (httpClient.request as jest.Mock).mock.calls[0]; + expect(url).toBe("/_localstack/eventstudio/v1/spans"); + expect(options.method).toBe("DELETE"); + expect(options.headers["Content-Type"]).toBe("application/json"); + expect(JSON.parse(options.body)).toEqual({ span_ids: ["a", "b"] }); + }); + + test("clearEvents sends DELETE without body when no spanIds provided", async () => { + const client = new ApplicationInspectorApiClient(); + (httpClient.request as jest.Mock).mockResolvedValueOnce({ deleted_count: 0 }); + + await client.clearEvents(); + const [url, options] = (httpClient.request as jest.Mock).mock.calls[0]; + expect(url).toBe("/_localstack/eventstudio/v1/spans"); + expect(options.method).toBe("DELETE"); + expect(options.body).toBeUndefined(); + }); +}); diff --git a/src/lib/application-inspector/application-inspector.client.ts b/src/lib/application-inspector/application-inspector.client.ts new file mode 100644 index 0000000..62d04fe --- /dev/null +++ b/src/lib/application-inspector/application-inspector.client.ts @@ -0,0 +1,89 @@ +import { httpClient, HttpError } from "../../core/http-client"; + +export interface InspectorEvent { + event_id: string; + name: string; + timestamp_unix_nano: number; + attributes: Record | null; +} + +export interface InspectorSpan { + span_id: string; + trace_id: string; + parent_span_id: string | null; + start_time_unix_nano: number; + end_time_unix_nano: number; + status_code: number; + status_message: string | null; + service_name: string; + operation_name: string; + is_write_operation: boolean; + events: InspectorEvent[]; + attributes: Record | null; + parent_span?: { service_name: string }; +} + +export interface SpanPage { + spans: InspectorSpan[]; + next_token: string | null; +} + +export interface SpanFilters { + limit?: number; + pagination_token?: string; + service_name?: string; + operation_name?: string; + trace_id?: string; + errors_only?: boolean; + account_id?: string; + region?: string; + status_code?: number; + parent_span_id?: string; + span_id?: string; + arn?: string; + resource_name?: string; + start_time_unix_nano?: number; + end_time_unix_nano?: number; + version?: number; +} + +export class ApplicationInspectorApiClient { + async getSpans(filters: SpanFilters = {}): Promise { + const queryParams = new URLSearchParams(); + + if (typeof filters.limit === "number") { + queryParams.set("limit", String(filters.limit)); + } + + for (const [key, value] of Object.entries(filters)) { + if (value === undefined || value === null) continue; + if (key === "errors_only") continue; + if (key === "limit") continue; + queryParams.set(key, String(value as any)); + } + + if (filters.errors_only) { + queryParams.set("status_code", "2"); + } + + const qs = queryParams.toString(); + const primary = `/_localstack/eventstudio/v1/spans?${qs}`; + return httpClient.request(primary, { method: "GET" }); + } + + async clearEvents(spanIds?: string[]): Promise<{ deleted_count: number }> { + const hasIds = Array.isArray(spanIds) && spanIds.length > 0; + const options: RequestInit = hasIds + ? { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ span_ids: spanIds }), + } + : { method: "DELETE" }; + + return httpClient.request<{ deleted_count: number }>( + "/_localstack/eventstudio/v1/spans", + options + ); + } +} diff --git a/src/lib/application-inspector/inspector-reporter.test.ts b/src/lib/application-inspector/inspector-reporter.test.ts new file mode 100644 index 0000000..c2186a8 --- /dev/null +++ b/src/lib/application-inspector/inspector-reporter.test.ts @@ -0,0 +1,91 @@ +import { formatTraceSummaryTable, formatDetailedTraceView } from "./inspector-reporter"; +import { type SpanPage, type InspectorSpan } from "./application-inspector.client"; + +function ns(nowMs: number) { + return nowMs * 1_000_000; +} + +describe("Inspector Reporter", () => { + const baseTimeMs = 1700000000000; + + const spanA: InspectorSpan = { + span_id: "A", + trace_id: "T1", + parent_span_id: null, + start_time_unix_nano: ns(baseTimeMs), + end_time_unix_nano: ns(baseTimeMs + 120), + status_code: 0, + status_message: null, + service_name: "apigateway", + operation_name: "POST /users", + is_write_operation: true, + events: [ + { + event_id: "E1", + name: "http.request", + timestamp_unix_nano: ns(baseTimeMs + 10), + attributes: { path: "/users", method: "POST" }, + }, + ], + attributes: { http_status: 201 }, + }; + + const spanB: InspectorSpan = { + span_id: "B", + trace_id: "T1", + parent_span_id: "A", + start_time_unix_nano: ns(baseTimeMs + 20), + end_time_unix_nano: ns(baseTimeMs + 60), + status_code: 0, + status_message: null, + service_name: "lambda", + operation_name: "InvokeFunction", + is_write_operation: false, + events: [], + attributes: null, + parent_span: { service_name: "apigateway" }, + }; + + const spanCError: InspectorSpan = { + span_id: "C", + trace_id: "T2", + parent_span_id: null, + start_time_unix_nano: ns(baseTimeMs + 5), + end_time_unix_nano: ns(baseTimeMs + 8), + status_code: 2, + status_message: "Boom", + service_name: "s3", + operation_name: "PutObject", + is_write_operation: true, + events: [ + { + event_id: "E2", + name: "sdk.error", + timestamp_unix_nano: ns(baseTimeMs + 7), + attributes: { code: "AccessDenied" }, + }, + ], + attributes: null, + }; + + test("formatTraceSummaryTable returns table with grouped traces and status", () => { + const page: SpanPage = { + spans: [spanA, spanB, spanCError], + next_token: null, + }; + + const table = formatTraceSummaryTable(page); + expect(table).toContain( + "| Trace Start Time | Root Operation | Services | Duration | Status | Trace ID |" + ); + expect(table).toContain("apigateway:POST /users"); + expect(table).toContain("lambda"); + expect(table).toContain("T1"); + expect(table).toContain("❌"); + }); + + test("formatDetailedTraceView snapshot", () => { + const view = formatDetailedTraceView([spanA, spanB]); + expect(view).toMatchSnapshot(); + }); +}); diff --git a/src/lib/application-inspector/inspector-reporter.ts b/src/lib/application-inspector/inspector-reporter.ts new file mode 100644 index 0000000..4986e09 --- /dev/null +++ b/src/lib/application-inspector/inspector-reporter.ts @@ -0,0 +1,122 @@ +import { type InspectorSpan, type SpanPage } from "./application-inspector.client"; + +function formatNsToMs(ns: number): string { + const ms = ns / 1_000_000; + return `${ms.toFixed(2)} ms`; +} + +function formatUnixNanoToIso(ns: number): string { + const ms = Math.floor(ns / 1_000_000); + return new Date(ms).toISOString(); +} + +export function formatTraceSummaryTable(spanPage: SpanPage): string { + const spans = spanPage.spans || []; + if (spans.length === 0) { + return "No spans found."; + } + + const byTrace = new Map(); + for (const s of spans) { + const arr = byTrace.get(s.trace_id) || []; + arr.push(s); + byTrace.set(s.trace_id, arr); + } + + const lines: string[] = []; + lines.push("| Trace Start Time | Root Operation | Services | Duration | Status | Trace ID |"); + lines.push("|---|---|---|---|---|---|"); + + for (const [traceId, tSpans] of byTrace.entries()) { + let root = tSpans.find((s) => !s.parent_span_id) || tSpans[0]; + + let earliest = Number.POSITIVE_INFINITY; + let latest = 0; + let hasError = false; + const services = new Set(); + + for (const s of tSpans) { + services.add(s.service_name); + if (s.start_time_unix_nano < earliest) earliest = s.start_time_unix_nano; + if (s.end_time_unix_nano > latest) latest = s.end_time_unix_nano; + // OpenTelemetry status codes: 0=UNSET, 1=OK, 2=ERROR + if (s.status_code === 2) hasError = true; + } + + const startIso = formatUnixNanoToIso(root.start_time_unix_nano); + const rootOp = `${root.service_name}:${root.operation_name}`; + const duration = latest > earliest ? formatNsToMs(latest - earliest) : "0.00 ms"; + const status = hasError ? "❌" : "✅"; + const servicesList = Array.from(services).sort().join(", "); + + lines.push( + `| ${startIso} | ${rootOp} | ${servicesList} | ${duration} | ${status} | ${traceId} |` + ); + } + + return lines.join("\n"); +} + +export function formatDetailedTraceView(traceSpans: InspectorSpan[]): string { + if (!traceSpans || traceSpans.length === 0) return "No spans for this trace."; + + // Build index + const nodeById = new Map(); + for (const s of traceSpans) { + nodeById.set(s.span_id, { span: s, children: [] }); + } + const roots: InspectorSpan[] = []; + for (const s of traceSpans) { + if (s.parent_span_id && nodeById.has(s.parent_span_id)) { + nodeById.get(s.parent_span_id)!.children.push(s); + } else { + roots.push(s); + } + } + + for (const node of nodeById.values()) { + node.children.sort((a, b) => a.start_time_unix_nano - b.start_time_unix_nano); + } + roots.sort((a, b) => a.start_time_unix_nano - b.start_time_unix_nano); + + const lines: string[] = []; + + function renderSpan(span: InspectorSpan, indent: number) { + const pad = " ".repeat(indent); + const ok = span.status_code !== 2; + const emoji = ok ? "✅" : "❌"; + const durationNs = Math.max(0, span.end_time_unix_nano - span.start_time_unix_nano); + const duration = formatNsToMs(durationNs); + const op = `${span.service_name}:${span.operation_name}`; + const parentInfo = span.parent_span?.service_name + ? ` (parent: ${span.parent_span.service_name})` + : ""; + + lines.push(`${pad}- [${emoji} ${op}] - ${duration}${parentInfo}`); + + if (span.events && span.events.length > 0) { + for (const ev of span.events) { + const evPad = " ".repeat(indent + 1); + const evTime = formatUnixNanoToIso(ev.timestamp_unix_nano); + lines.push(`${evPad}- event: ${ev.name} @ ${evTime}`); + if (ev.attributes && Object.keys(ev.attributes).length > 0) { + const json = JSON.stringify(ev.attributes, null, 2); + lines.push(`${evPad}\n${evPad}\u0060\u0060\u0060json`); + for (const line of json.split("\n")) { + lines.push(`${evPad}${line}`); + } + lines.push(`${evPad}\u0060\u0060\u0060`); + } + } + } + + const children = nodeById.get(span.span_id)?.children || []; + for (const child of children) { + renderSpan(child, indent + 1); + } + } + + for (const r of roots) renderSpan(r, 0); + + return lines.join("\n"); +} diff --git a/src/lib/docker/docker.client.test.ts b/src/lib/docker/docker.client.test.ts index 41df07a..d65dc47 100644 --- a/src/lib/docker/docker.client.test.ts +++ b/src/lib/docker/docker.client.test.ts @@ -92,8 +92,7 @@ describe("DockerApiClient", () => { mocks.start.mockImplementationOnce((opts: any, cb: any) => { setImmediate(() => { cb(null, stream); - setImmediate(() => { - }); + setImmediate(() => {}); }); }); diff --git a/src/tools/localstack-application-inspector.ts b/src/tools/localstack-application-inspector.ts new file mode 100644 index 0000000..3ccdf94 --- /dev/null +++ b/src/tools/localstack-application-inspector.ts @@ -0,0 +1,146 @@ +import { z } from "zod"; +import { type ToolMetadata, type InferSchema } from "xmcp"; +import { runPreflights, requireLocalStackRunning } from "../core/preflight"; +import { ResponseBuilder } from "../core/response-builder"; +import { HttpError } from "../core/http-client"; +import { + ApplicationInspectorApiClient, + type SpanFilters, + type InspectorSpan, +} from "../lib/application-inspector/application-inspector.client"; +import { + formatTraceSummaryTable, + formatDetailedTraceView, +} from "../lib/application-inspector/inspector-reporter"; + +export const schema = { + action: z.enum(["list-traces", "get-trace-details", "clear-traces"]), + + // list-traces filters + serviceName: z.string().optional(), + operationName: z.string().optional(), + errorsOnly: z.boolean().optional(), + limit: z.number().optional(), + paginationToken: z.string().optional(), + traceId: z.string().optional(), + accountId: z.string().optional(), + region: z.string().optional(), + startTimeUnixNano: z.number().optional(), + endTimeUnixNano: z.number().optional(), + format: z.enum(["table", "json"]).default("table"), + + // get-trace-details + traceIdRequired: z.string().optional(), + + // clear-traces + spanIds: z.array(z.string()).optional(), +}; + +export const metadata: ToolMetadata = { + name: "localstack-application-inspector", + description: + "Inspects and visualizes end-to-end request flows within your application. Use this tool to trace a single request's journey across multiple AWS services, understand performance bottlenecks, and debug complex, distributed workflows.", + annotations: { + title: "LocalStack Application Inspector", + }, +}; + +export default async function localstackApplicationInspector(params: InferSchema) { + // const preflightError = await runPreflights([requireLocalStackRunning()]); + // if (preflightError) return preflightError; + + const client = new ApplicationInspectorApiClient(); + + switch (params.action) { + case "list-traces": { + const filters: SpanFilters = { + limit: params.limit, + pagination_token: params.paginationToken, + service_name: params.serviceName, + operation_name: params.operationName, + errors_only: params.errorsOnly, + trace_id: params.traceId, + account_id: params.accountId, + region: params.region, + start_time_unix_nano: params.startTimeUnixNano, + end_time_unix_nano: params.endTimeUnixNano, + }; + try { + const page = await client.getSpans(filters); + if (params.format === "json") { + return ResponseBuilder.json(page); + } + const table = formatTraceSummaryTable(page); + return ResponseBuilder.markdown(table); + } catch (err: any) { + if (err instanceof HttpError) { + return ResponseBuilder.error( + "Application Inspector API Error", + `Status ${err.status}: ${err.statusText}\n\n${err.body}` + ); + } + return ResponseBuilder.error( + "Application Inspector Error", + err instanceof Error ? err.message : String(err) + ); + } + } + + case "get-trace-details": { + const traceId = params.traceIdRequired || params.traceId; + if (!traceId) { + return ResponseBuilder.error( + "Missing Required Parameter", + "The 'get-trace-details' action requires the 'traceId' parameter." + ); + } + try { + const allSpans: InspectorSpan[] = []; + let paginationToken: string | undefined = undefined; + let safetyCounter = 0; + do { + const page = await client.getSpans({ + trace_id: traceId, + limit: 1000, + pagination_token: paginationToken, + }); + if (page.spans && page.spans.length > 0) allSpans.push(...page.spans); + paginationToken = page.next_token || undefined; + safetyCounter++; + } while (paginationToken && safetyCounter < 50); + + const view = formatDetailedTraceView(allSpans); + return ResponseBuilder.markdown(view); + } catch (err: any) { + if (err instanceof HttpError) { + return ResponseBuilder.error( + "Application Inspector API Error", + `Status ${err.status}: ${err.statusText}\n\n${err.body}` + ); + } + return ResponseBuilder.error( + "Application Inspector Error", + err instanceof Error ? err.message : String(err) + ); + } + } + + case "clear-traces": { + try { + const result = await client.clearEvents(params.spanIds); + return ResponseBuilder.success(`Successfully deleted ${result.deleted_count} event(s).`); + } catch (err: any) { + if (err instanceof HttpError) { + return ResponseBuilder.error( + "Application Inspector API Error", + `Status ${err.status}: ${err.statusText}\n\n${err.body}` + ); + } + return ResponseBuilder.error( + "Application Inspector Error", + err instanceof Error ? err.message : String(err) + ); + } + } + } +} diff --git a/src/tools/localstack-aws-client.ts b/src/tools/localstack-aws-client.ts index dd6cdf1..a54778b 100644 --- a/src/tools/localstack-aws-client.ts +++ b/src/tools/localstack-aws-client.ts @@ -47,8 +47,12 @@ export default async function localstackAwsClient({ command }: InferSchema