Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 28 additions & 24 deletions README.md

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@
{
"name": "localstack-docs",
"description": "Search the LocalStack documentation to find guides, API references, and configuration details"
},
{
"name": "localstack-app-inspector",
"description": "Inspect LocalStack application traces, spans, events, payload metadata, and IAM policy evaluations"
}
],
"prompts": [
Expand Down Expand Up @@ -153,6 +157,23 @@
"arguments": ["job_id"],
"text": "Check the AWS Replicator job status for ${arguments.job_id}."
},
{
"name": "app-inspector-enable",
"description": "Enable App Inspector on the running LocalStack instance",
"text": "Enable LocalStack App Inspector so I can capture traces, spans, events, payload metadata, and IAM policy evaluations for the next AWS workload I run locally."
},
{
"name": "app-inspector-inspect-application-flow",
"description": "Inspect recent LocalStack application flows with App Inspector",
"arguments": ["description"],
"text": "Use LocalStack App Inspector to investigate ${arguments.description}. Check whether App Inspector is enabled, list recent traces, identify relevant spans, then inspect events and IAM policy evaluation events for the AWS service flow."
},
{
"name": "app-inspector-service-traces",
"description": "Find App Inspector traces for a specific AWS service",
"arguments": ["service_name"],
"text": "List recent App Inspector traces for the ${arguments.service_name} service, then help me drill into spans and events that explain what happened in LocalStack."
},
{
"name": "cloud-pods",
"description": "Manage LocalStack state snapshots (Cloud Pods) for saving, loading, deleting, and resetting",
Expand Down
7 changes: 7 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@
"format": "string",
"is_secret": false,
"name": "AWS_REPLICATOR_SOURCE_ENDPOINT_URL"
},
{
"description": "Set to 1 in the LocalStack container environment to enable App Inspector by default across restarts. The MCP tool can also toggle App Inspector at runtime.",
"is_required": false,
"format": "string",
"is_secret": false,
"name": "APP_INSPECTOR"
}
]
}
Expand Down
12 changes: 12 additions & 0 deletions src/core/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ export const TOOL_ARG_ALLOWLIST: Record<string, string[]> = {
"has_resource_identifier",
"has_resource_arn",
],
"localstack-app-inspector": [
"action",
"target_status",
"has_trace_id",
"has_span_id",
"has_event_id",
"trace_ids_count",
"span_ids_count",
"limit",
"has_pagination_token",
"filter_keys",
],
"localstack-chaos-injector": ["action", "rules_count", "latency_ms"],
"localstack-cloud-pods": ["action", "pod_name"],
"localstack-deployer": [
Expand Down
153 changes: 153 additions & 0 deletions src/lib/localstack/app-inspector.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import {
buildAppInspectorAnalyticsArgs,
buildQueryString,
formatAppInspectorApiError,
formatEventList,
formatSpanList,
formatTraceList,
} from "../../tools/localstack-app-inspector";

describe("localstack-app-inspector", () => {
describe("buildQueryString", () => {
it("encodes defined query parameters and skips undefined values", () => {
expect(
buildQueryString({
service_name: "lambda",
operation_name: "Send Message",
arn: "arn:aws:sqs:us-east-1:000000000000:queue/name",
region: undefined,
})
).toBe(
"?service_name=lambda&operation_name=Send%20Message&arn=arn%3Aaws%3Asqs%3Aus-east-1%3A000000000000%3Aqueue%2Fname"
);
});
});

describe("buildAppInspectorAnalyticsArgs", () => {
it("keeps analytics privacy-safe by reporting shape instead of raw IDs", () => {
const analyticsArgs = buildAppInspectorAnalyticsArgs({
action: "get-event",
trace_id: "f".repeat(32),
span_id: "a".repeat(16),
event_id: "b".repeat(16),
account_id: "000000000000",
arn: "arn:aws:sqs:us-east-1:000000000000:queue/name",
limit: 25,
pagination_token: "cursor",
} as any);

expect(analyticsArgs).toEqual({
action: "get-event",
target_status: undefined,
has_trace_id: true,
has_span_id: true,
has_event_id: true,
trace_ids_count: undefined,
span_ids_count: undefined,
limit: 25,
has_pagination_token: true,
filter_keys: "account_id,arn,event_id,limit,pagination_token,span_id,trace_id",
});
expect(JSON.stringify(analyticsArgs)).not.toContain("000000000000");
expect(JSON.stringify(analyticsArgs)).not.toContain("arn:aws:sqs");
});

it("tracks delete request cardinality without raw IDs", () => {
const analyticsArgs = buildAppInspectorAnalyticsArgs({
action: "delete-spans",
trace_id: "f".repeat(32),
span_ids: ["a".repeat(16), "b".repeat(16)],
} as any);

expect(analyticsArgs.span_ids_count).toBe(2);
expect(analyticsArgs.has_trace_id).toBe(true);
expect(JSON.stringify(analyticsArgs)).not.toContain("aaaaaaaaaaaaaaaa");
});
});

describe("formatTraceList", () => {
it("formats trace summaries returned by the App Inspector API", () => {
const formatted = formatTraceList({
traces: [
{
trace_id: "f".repeat(32),
service_count: 3,
span_count: 5,
error_count: 1,
status_code: 2,
start_time_unix_nano: "1710000000000000000",
},
],
pagination: {
total_count: 1,
has_next: false,
},
});

expect(formatted).toContain("## Traces (1)");
expect(formatted).toContain("ffffffffffffffffffffffffffffffff");
expect(formatted).toContain("Pagination");
});
});

describe("formatAppInspectorApiError", () => {
it("turns disabled App Inspector responses into actionable guidance", () => {
const formatted = formatAppInspectorApiError({
success: false,
statusCode: 503,
message: "AppInspector is not enabled",
});

expect(formatted.content[0].text).toContain("App Inspector Disabled");
expect(formatted.content[0].text).toContain("set-status");
expect(formatted.content[0].text).toContain("enabled");
});
});

describe("formatSpanList", () => {
it("formats span summaries with service and operation details", () => {
const formatted = formatSpanList(
{
spans: [
{
span_id: "a".repeat(16),
service_name: "lambda",
operation_name: "Invoke",
status_code: 1,
start_time_unix_nano: "1710000000000000000",
},
],
pagination: { total_count: 1, has_next: false },
},
"f".repeat(32)
);

expect(formatted).toContain("## Spans for Trace");
expect(formatted).toContain("lambda");
expect(formatted).toContain("Invoke");
});
});

describe("formatEventList", () => {
it("formats event summaries including IAM policy events", () => {
const formatted = formatEventList(
{
events: [
{
event_id: "b".repeat(16),
name: "iam.policy.denied",
event_type: "iam.policy_evaluation",
timestamp_unix_nano: "1710000000000000000",
},
],
},
"a".repeat(16),
"IAM Policy Evaluation "
);

expect(formatted).toContain("## IAM Policy Evaluation Events");
expect(formatted).toContain("iam.policy.denied");
expect(formatted).toContain("iam.policy_evaluation");
});
});
});
1 change: 1 addition & 0 deletions src/lib/localstack/license-checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

export enum ProFeature {
APP_INSPECTOR = "localstack.platform.plugin/appinspector",
IAM_ENFORCEMENT = "localstack.platform.plugin/iam-enforcement",
CLOUD_PODS = "localstack.platform.plugin/pods",
CHAOS_ENGINEERING = "localstack.platform.plugin/chaos",
Expand Down
117 changes: 117 additions & 0 deletions src/lib/localstack/localstack.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ export interface ReplicationSupportedResource {
[key: string]: unknown;
}

export interface AppInspectorStatusResponse {
status: string;
note?: string;
}

export interface AppInspectorSetStatusResponse {
status: string;
changed: boolean;
}

export type AppInspectorStatus = "enabled" | "disabled";
export type AppInspectorQuery = Record<string, string | number | undefined>;

// Chaos API Client
export class ChaosApiClient {
private async makeRequest(
Expand Down Expand Up @@ -222,3 +235,107 @@ export class AwsReplicatorApiClient {
return this.makeRequest<ReplicationSupportedResource[]>("/resources", "GET");
}
}

// App Inspector API Client
export class AppInspectorApiClient {
private buildQueryString(params: AppInspectorQuery): string {
const parts = Object.entries(params)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
return parts.length ? `?${parts.join("&")}` : "";
}

private async makeRequest<T>(
endpoint: string,
method: "GET" | "PUT" | "DELETE",
body?: unknown
): Promise<ApiResult<T>> {
try {
const data = await httpClient.request<T>(`/_localstack/appinspector${endpoint}`, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
return { success: true, data };
} catch (error) {
if (error instanceof HttpError) {
return {
success: false,
message: error.body || error.message,
statusCode: error.status,
};
}
return {
success: false,
message: `Failed to communicate with LocalStack App Inspector API: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
}

getStatus() {
return this.makeRequest<AppInspectorStatusResponse>("/status", "GET");
}

setStatus(status: AppInspectorStatus) {
return this.makeRequest<AppInspectorSetStatusResponse>("/status", "PUT", { status });
}

listTraces(query: AppInspectorQuery) {
return this.makeRequest<any>(`/v1/traces${this.buildQueryString(query)}`, "GET");
}

getTrace(traceId: string) {
return this.makeRequest<any>(`/v1/traces/${encodeURIComponent(traceId)}`, "GET");
}

deleteTraces(traceIds?: string[]) {
return this.makeRequest<{ deleted_count: number }>(
"/v1/traces",
"DELETE",
traceIds ? { trace_ids: traceIds } : {}
);
}

listSpans(traceId: string, query: AppInspectorQuery) {
return this.makeRequest<any>(
`/v1/traces/${encodeURIComponent(traceId)}/spans${this.buildQueryString(query)}`,
"GET"
);
}

getSpan(traceId: string, spanId: string) {
return this.makeRequest<any>(
`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}`,
"GET"
);
}

deleteSpans(traceId: string, spanIds?: string[]) {
return this.makeRequest<{ deleted_count: number }>(
`/v1/traces/${encodeURIComponent(traceId)}/spans`,
"DELETE",
spanIds ? { span_ids: spanIds } : {}
);
}

listEvents(traceId: string, spanId: string, query: AppInspectorQuery) {
return this.makeRequest<any>(
`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}/events${this.buildQueryString(query)}`,
"GET"
);
}

getEvent(traceId: string, spanId: string, eventId: string) {
return this.makeRequest<any>(
`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}/events/${encodeURIComponent(eventId)}`,
"GET"
);
}

listIamEvents(traceId: string, spanId: string, query: AppInspectorQuery) {
return this.makeRequest<any>(
`/v1/traces/${encodeURIComponent(traceId)}/spans/${encodeURIComponent(spanId)}/events/iam${this.buildQueryString(query)}`,
"GET"
);
}
}
Loading
Loading