@@ -33,7 +33,7 @@ export const ExportLogsServiceRequest = logs.v1.ExportLogsServiceRequest;
3333type OtlpDecoder = Pick < OtlpMessageType , "decode" > ;
3434
3535export 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 {
4646export 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 */
6062export 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 */
130157export 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" ,
0 commit comments