Skip to content

Commit 10be0a6

Browse files
authored
fix(dev): harden OTEL collector, container reachability, and dev lifecycle
Address reviewer findings on the collector and dev wiring: - Validate top-level OTLP shape and return 400 instead of mislabeling a bad payload as a persistence error. - Guard the shared HTTP server against a client that disconnects mid-response so it can no longer crash project dev; add an optional bind host. - Bind the collector to 0.0.0.0 for container runtimes so a container can reach it over the host bridge on Linux. - Run the container template under opentelemetry-instrument so it emits traces. - Keep the collector alive through the child's shutdown grace so final spans are not lost. - Force the OTEL settings that would otherwise let shell or .env.local values disable or break local collection. - Make the uv sitecustomize discovery abortable and read its path from a marker rather than the last merged output line.
1 parent 8cabff7 commit 10be0a6

12 files changed

Lines changed: 227 additions & 55 deletions

File tree

src/assets/templates/hello-world-python-container/Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,6 @@ USER bedrock_agentcore
3434
# 9000: A2A Mode
3535
EXPOSE 8080 8000 9000
3636

37-
CMD ["python", "-m", "main"]
37+
# opentelemetry-instrument (from aws-opentelemetry-distro) starts a real
38+
# TracerProvider; plain `python -m main` would export nothing.
39+
CMD ["opentelemetry-instrument", "python", "-m", "main"]

src/core/dev/codezip.test.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,21 +54,25 @@ async function projectRoot(withNodeModules = false): Promise<string> {
5454
return root;
5555
}
5656

57-
function harness(output: ProcessEvent[] = [], probe: { dir?: string; fail?: boolean } = {}) {
57+
function harness(
58+
output: ProcessEvent[] = [],
59+
site: { dir?: string; fail?: boolean; noise?: string[] } = {},
60+
) {
5861
const calls: ProcessCall[] = [];
59-
const probeCalls: string[][] = [];
62+
const discoverCalls: string[][] = [];
6063
const fakeStreamProcess: ProcessStreamer = async function* (command, options) {
6164
calls.push({ command, options });
6265
yield* output;
6366
};
6467
const fakeRunProcess: ProcessRunner = async (command, options) => {
65-
probeCalls.push(command);
66-
if (probe.fail) throw new Error("probe failed");
67-
options.onOutput?.(`${probe.dir ?? ""}\n`);
68+
discoverCalls.push(command);
69+
if (site.fail) throw new Error("discovery failed");
70+
for (const line of site.noise ?? []) options.onOutput?.(`${line}\n`);
71+
if (site.dir !== undefined) options.onOutput?.(`AGENTCORE_OTEL_SITECUSTOMIZE=${site.dir}\n`);
6872
};
6973
return {
7074
calls,
71-
probeCalls,
75+
discoverCalls,
7276
runner: new CodeZipDevRunner({ streamProcess: fakeStreamProcess, runProcess: fakeRunProcess }),
7377
};
7478
}
@@ -220,11 +224,24 @@ describe("CodeZipDevRunner OTEL instrumentation", () => {
220224
test("prepends the sitecustomize directory to PYTHONPATH when instrumentation is installed", async () => {
221225
const root = await projectRoot();
222226
const directory = await sitecustomizeDir();
223-
const { calls, probeCalls, runner } = harness([], { dir: directory });
227+
const { calls, discoverCalls, runner } = harness([], { dir: directory });
228+
229+
await collect(runner.run(otelInput(root)));
230+
231+
expect(discoverCalls[0]?.slice(0, 4)).toEqual(["uv", "run", "python", "-c"]);
232+
expect(calls[0]?.options.env?.PYTHONPATH).toBe(directory);
233+
});
234+
235+
test("reads the marked path even when uv writes progress to the merged output", async () => {
236+
const root = await projectRoot();
237+
const directory = await sitecustomizeDir();
238+
const { calls, runner } = harness([], {
239+
dir: directory,
240+
noise: ["Resolved 12 packages", "Installed 12 packages"],
241+
});
224242

225243
await collect(runner.run(otelInput(root)));
226244

227-
expect(probeCalls[0]?.slice(0, 4)).toEqual(["uv", "run", "python", "-c"]);
228245
expect(calls[0]?.options.env?.PYTHONPATH).toBe(directory);
229246
});
230247

@@ -238,26 +255,26 @@ describe("CodeZipDevRunner OTEL instrumentation", () => {
238255
expect(calls[0]?.options.env?.PYTHONPATH).toBe(`${directory}${delimiter}/existing`);
239256
});
240257

241-
test("does not probe without an OTEL endpoint or for Node entrypoints", async () => {
258+
test("does not run discovery without an OTEL endpoint or for Node entrypoints", async () => {
242259
const root = await projectRoot(true);
243-
const { probeCalls, runner } = harness();
260+
const { discoverCalls, runner } = harness();
244261

245262
await collect(runner.run(input(root, runtime())));
246263
await collect(runner.run({ ...otelInput(root), runtime: runtime({ entrypoint: "index.js" }) }));
247264

248-
expect(probeCalls).toEqual([]);
265+
expect(discoverCalls).toEqual([]);
249266
});
250267

251268
test.each([
252-
["probe failure", { fail: true }],
269+
["discovery failure", { fail: true }],
253270
["missing sitecustomize.py", { dir: "/nonexistent" }],
254-
] as const)("warns and starts untraced on %s", async (_case, probe) => {
271+
] as const)("warns and starts untraced on %s", async (_case, site) => {
255272
const root = await projectRoot();
256-
const { calls, probeCalls, runner } = harness([], probe);
273+
const { calls, discoverCalls, runner } = harness([], site);
257274

258275
const events = await collect(runner.run(otelInput(root)));
259276

260-
expect(probeCalls).toHaveLength(1);
277+
expect(discoverCalls).toHaveLength(1);
261278
expect(calls).toHaveLength(1);
262279
expect(calls[0]?.options.env?.PYTHONPATH).toBeUndefined();
263280
expect(events).toContainEqual({

src/core/dev/codezip.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ type CodeZipDevRunnerConfig = {
1616
runProcess?: ProcessRunner;
1717
};
1818

19+
const SITECUSTOMIZE_MARKER = "AGENTCORE_OTEL_SITECUSTOMIZE=";
20+
1921
export class CodeZipDevRunner implements DevRunner {
2022
private readonly streamProcess: ProcessStreamer;
2123
private readonly runProcess: ProcessRunner;
@@ -51,7 +53,7 @@ export class CodeZipDevRunner implements DevRunner {
5153
yield { type: "status", message: "Starting development server" };
5254
const serverProcess = commandForRuntime(entrypoint!, directory, input);
5355
if (entrypoint!.endsWith(".py") && input.env?.OTEL_EXPORTER_OTLP_ENDPOINT) {
54-
const sitecustomizeDir = await this.findOtelSitecustomizeDir(directory);
56+
const sitecustomizeDir = await this.findOtelSitecustomizeDir(directory, input.signal);
5557
if (sitecustomizeDir) {
5658
const existing = serverProcess.options.env?.PYTHONPATH;
5759
serverProcess.options.env = {
@@ -75,19 +77,29 @@ export class CodeZipDevRunner implements DevRunner {
7577
* an `opentelemetry-instrument` wrapper would only instrument uvicorn's reloader
7678
* parent, leaving the re-spawned worker processes untraced.
7779
*/
78-
private async findOtelSitecustomizeDir(directory: string): Promise<string | undefined> {
80+
private async findOtelSitecustomizeDir(
81+
directory: string,
82+
signal: AbortSignal,
83+
): Promise<string | undefined> {
7984
const output: string[] = [];
80-
const probe =
81-
"import opentelemetry.instrumentation.auto_instrumentation as m; import os; print(os.path.dirname(m.__file__))";
85+
// uv writes sync progress to stderr, which merges into onOutput, so the path
86+
// is printed behind a marker and read from that line rather than the last one.
87+
const script = `import opentelemetry.instrumentation.auto_instrumentation as m, os; print("${SITECUSTOMIZE_MARKER}" + os.path.dirname(m.__file__))`;
8288
try {
83-
await this.runProcess(["uv", "run", "python", "-c", probe], {
89+
await this.runProcess(["uv", "run", "python", "-c", script], {
8490
cwd: directory,
8591
onOutput: (chunk) => output.push(chunk),
92+
signal,
8693
});
8794
} catch {
8895
return undefined;
8996
}
90-
const sitecustomizeDir = output.join("").trim().split("\n").at(-1)?.trim();
97+
const marked = output
98+
.join("")
99+
.split("\n")
100+
.map((line) => line.trim())
101+
.find((line) => line.startsWith(SITECUSTOMIZE_MARKER));
102+
const sitecustomizeDir = marked?.slice(SITECUSTOMIZE_MARKER.length);
91103
if (!sitecustomizeDir || !existsSync(join(sitecustomizeDir, "sitecustomize.py")))
92104
return undefined;
93105
return sitecustomizeDir;

src/core/dev/otel/collector.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,29 @@ describe("startOtelCollector", () => {
138138
expect(await collector.store.list()).toEqual([]);
139139
});
140140

141+
test.each(["null", "[]", "42", '{"resourceSpans":5}'])(
142+
"rejects structurally invalid JSON %s with 400 instead of a persistence error",
143+
async (body) => {
144+
const errors: unknown[] = [];
145+
const strict = await startOtelCollector({
146+
tracesDirectory: directory,
147+
onError: (error) => errors.push(error),
148+
});
149+
try {
150+
const response = await fetch(`http://127.0.0.1:${strict.port}/v1/traces`, {
151+
method: "POST",
152+
headers: { "Content-Type": "application/json" },
153+
body,
154+
});
155+
expect(response.status).toBe(400);
156+
expect(errors).toEqual([]);
157+
expect(await strict.store.list()).toEqual([]);
158+
} finally {
159+
await strict.close();
160+
}
161+
},
162+
);
163+
141164
test("acks with 200 and reports onError when persistence fails", async () => {
142165
// A traces dir nested under a regular file makes mkdir (and thus append) fail.
143166
const blocker = join(directory, "blocker");
@@ -184,6 +207,17 @@ describe("startOtelCollector", () => {
184207
});
185208
});
186209

210+
test("envVars force the settings that would otherwise break local collection", () => {
211+
expect(collector.envVars).toMatchObject({
212+
OTEL_SDK_DISABLED: "false",
213+
OTEL_TRACES_EXPORTER: "otlp",
214+
OTEL_LOGS_EXPORTER: "otlp",
215+
OTEL_EXPORTER_OTLP_COMPRESSION: "none",
216+
OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "none",
217+
OTEL_EXPORTER_OTLP_LOGS_COMPRESSION: "none",
218+
});
219+
});
220+
187221
test("abort signal closes the receiver", async () => {
188222
const controller = new AbortController();
189223
const aborted = await startOtelCollector({

src/core/dev/otel/collector.ts

Lines changed: 55 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const ExportLogsServiceRequest = logs.v1.ExportLogsServiceRequest;
3333
type OtlpDecoder = Pick<OtlpMessageType, "decode">;
3434

3535
export interface OtelCollector {
36-
/** The loopback port the OTLP/HTTP receiver listens on. */
36+
/** The port the OTLP/HTTP receiver listens on. */
3737
port: number;
3838
/** Reads the traces this collector persists. */
3939
store: TraceStore;
@@ -46,22 +46,25 @@ export interface OtelCollector {
4646
export interface StartOtelCollectorOptions {
4747
/** Directory to persist OTLP JSON Lines files into. */
4848
tracesDirectory: string;
49+
/** Address to bind. Defaults to 127.0.0.1; use 0.0.0.0 to reach it from a container. */
50+
host?: string;
4951
/** Closes the collector when aborted. */
5052
signal?: AbortSignal;
5153
/** Called when a batch can't be persisted; the export is still acked to stop retries. */
5254
onError?: (error: unknown) => void;
5355
}
5456

5557
/**
56-
* Starts an in-process OTLP/HTTP receiver for dev mode on an OS-assigned
57-
* loopback port. Accepts `POST /v1/traces` and `POST /v1/logs` in protobuf or
58-
* JSON encoding and appends the raw payloads to a TraceStore.
58+
* Starts an in-process OTLP/HTTP receiver for dev mode on an OS-assigned port.
59+
* Accepts `POST /v1/traces` and `POST /v1/logs` in protobuf or JSON encoding and
60+
* appends the raw payloads to a TraceStore.
5961
*/
6062
export async function startOtelCollector(
6163
options: StartOtelCollectorOptions,
6264
): Promise<OtelCollector> {
6365
const store = new TraceStore(options.tracesDirectory);
6466
const server = await startHttpServer((request) => route(request, store, options.onError), {
67+
host: options.host,
6568
signal: options.signal,
6669
});
6770

@@ -74,10 +77,10 @@ async function route(
7477
onError?: (error: unknown) => void,
7578
): Promise<HttpResponse> {
7679
if (request.method === "POST" && request.url === "/v1/traces") {
77-
return ingest(request, store, ExportTraceServiceRequest, onError);
80+
return ingest(request, store, ExportTraceServiceRequest, "resourceSpans", onError);
7881
}
7982
if (request.method === "POST" && request.url === "/v1/logs") {
80-
return ingest(request, store, ExportLogsServiceRequest, onError);
83+
return ingest(request, store, ExportLogsServiceRequest, "resourceLogs", onError);
8184
}
8285
if (request.method === "GET" && request.url === "/") {
8386
return json(200, { status: "ok" });
@@ -89,16 +92,20 @@ async function ingest(
8992
request: HttpRequest,
9093
store: TraceStore,
9194
decoder: OtlpDecoder,
95+
field: "resourceSpans" | "resourceLogs",
9296
onError?: (error: unknown) => void,
9397
): Promise<HttpResponse> {
94-
let payload: OtlpPayload;
98+
let decoded: unknown;
9599
try {
96-
payload = decodePayload(request.body, String(request.headers["content-type"] ?? ""), decoder);
100+
decoded = decodePayload(request.body, String(request.headers["content-type"] ?? ""), decoder);
97101
} catch {
98102
return json(400, { error: "Invalid OTLP payload" });
99103
}
104+
if (!isOtlpPayload(decoded, field)) {
105+
return json(400, { error: "Invalid OTLP payload" });
106+
}
100107
try {
101-
await store.append(payload);
108+
await store.append(decoded);
102109
} catch (error) {
103110
// A persistence failure (disk full, permissions) is the collector's problem,
104111
// not the agent's: ack the export anyway so the SDK exporter stops retrying the
@@ -108,24 +115,44 @@ async function ingest(
108115
return json(200, {});
109116
}
110117

111-
/**
112-
* Decode an OTLP payload. The JSON round-trip on the protobuf path converts the
113-
* message to plain objects (protobufjs renders Long as string and bytes as base64).
114-
*/
115-
function decodePayload(body: Buffer, contentType: string, decoder: OtlpDecoder): OtlpPayload {
118+
/** Decode an OTLP export body by its content type into a plain, unvalidated object. */
119+
function decodePayload(body: Buffer, contentType: string, decoder: OtlpDecoder): unknown {
116120
if (contentType.includes("application/json")) {
117-
return JSON.parse(body.toString()) as OtlpPayload;
121+
return JSON.parse(body.toString());
118122
}
119-
return JSON.parse(JSON.stringify(decoder.decode(new Uint8Array(body)))) as OtlpPayload;
123+
return decodeProtobufToPlainObject(body, decoder);
124+
}
125+
126+
/**
127+
* Decode a protobuf export and flatten it to plain objects. The JSON round-trip
128+
* is what does the flattening: protobufjs renders Long as string and bytes as
129+
* base64, which is exactly the wire shape the rest of the code reads.
130+
*/
131+
function decodeProtobufToPlainObject(body: Buffer, decoder: OtlpDecoder): unknown {
132+
return JSON.parse(JSON.stringify(decoder.decode(new Uint8Array(body))));
133+
}
134+
135+
/**
136+
* A payload is only valid when it is a plain object whose export field, if
137+
* present, is an array. This rejects non-objects and shapes like
138+
* `{ resourceSpans: 5 }` at the 400 boundary instead of letting them fail later
139+
* inside the store as a mislabeled persistence error.
140+
*/
141+
function isOtlpPayload(
142+
value: unknown,
143+
field: "resourceSpans" | "resourceLogs",
144+
): value is OtlpPayload {
145+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
146+
const records = (value as Record<string, unknown>)[field];
147+
return records === undefined || Array.isArray(records);
120148
}
121149

122150
/**
123-
* Environment for a spawned agent so its OTEL SDK exports to the collector at
124-
* `port`. Signal-specific variables are set alongside the generic ones because
125-
* they take precedence in the SDK — a stray OTEL_EXPORTER_OTLP_TRACES_ENDPOINT
126-
* from the shell or .env.local must not silently redirect traces elsewhere.
127-
* Per the OTEL spec, signal-specific endpoints are full URLs (the signal path
128-
* is only appended to the generic endpoint).
151+
* Env that points a spawned agent's OTEL SDK at the collector on `port`. While
152+
* tracing is on the CLI owns these settings, so nothing from the shell or
153+
* .env.local can turn collection off or break it: compression is off (the
154+
* collector reads bodies undecompressed) and the SDK and exporters stay on.
155+
* Signal-specific endpoints are full URLs and win over the generic one.
129156
*/
130157
export function otelEnvVars(port: number): Record<string, string> {
131158
const endpoint = `http://127.0.0.1:${port}`;
@@ -136,6 +163,12 @@ export function otelEnvVars(port: number): Record<string, string> {
136163
OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf",
137164
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf",
138165
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/protobuf",
166+
OTEL_EXPORTER_OTLP_COMPRESSION: "none",
167+
OTEL_EXPORTER_OTLP_TRACES_COMPRESSION: "none",
168+
OTEL_EXPORTER_OTLP_LOGS_COMPRESSION: "none",
169+
OTEL_SDK_DISABLED: "false",
170+
OTEL_TRACES_EXPORTER: "otlp",
171+
OTEL_LOGS_EXPORTER: "otlp",
139172
OTEL_METRICS_EXPORTER: "none",
140173
AGENT_OBSERVABILITY_ENABLED: "true",
141174
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "true",

src/handlers/project/dev/index.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ describe("project dev trace collection", () => {
188188
expect(subject.collector.starts).toEqual([
189189
{
190190
tracesDirectory: join("/workspace/project", "agentcore", ".cli", "traces", "otlp"),
191-
signal: expect.any(AbortSignal),
191+
host: "127.0.0.1",
192192
onError: expect.any(Function),
193193
},
194194
]);
@@ -200,6 +200,15 @@ describe("project dev trace collection", () => {
200200
expect(subject.collector.state.closed).toBe(1);
201201
});
202202

203+
test("binds the collector to all interfaces so a container can reach it", async () => {
204+
const subject = harness({
205+
project: project(runtime("support", "Container")),
206+
});
207+
await subject.run();
208+
209+
expect(subject.collector.starts[0]?.host).toBe("0.0.0.0");
210+
});
211+
203212
test("reports a trace-persistence failure once, not per failed export", async () => {
204213
const subject = harness();
205214
await subject.run();

src/handlers/project/dev/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) =>
123123
let tracePersistErrorReported = false;
124124
collector = await config.startTraceCollector({
125125
tracesDirectory,
126-
signal: controller.signal,
126+
// A container reaches the collector over the host bridge, which a
127+
// 127.0.0.1 bind refuses, so the container path binds all interfaces.
128+
host: runtime.build === "Container" ? "0.0.0.0" : "127.0.0.1",
127129
// Persistence can fail after startup (disk, permissions). Warn once —
128130
// exports are still acked, so without this the loss would be silent.
129131
onError: (error) => {
@@ -167,6 +169,8 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) =>
167169
throw error;
168170
} finally {
169171
for (const signal of signals) process.removeListener(signal, interrupt);
172+
// Close only after the runner returns, which is after the child's own
173+
// shutdown grace, so the agent's final spans still reach the collector.
170174
await collector?.close();
171175
}
172176
},

0 commit comments

Comments
 (0)