Skip to content

Commit 1d566bc

Browse files
committed
feat(pipeline): add OTLP trace export config to the wasm binding
libdatadog's TraceExporter can export traces over OTLP HTTP (JSON or protobuf) instead of to the Datadog agent, mapping its internal traces to OTLP directly. Expose that on WasmSpanState so dd-trace-js can honour OTEL_TRACES_EXPORTER=otlp without resurrecting a JS-side OTLP exporter. - setOtlpEndpoint(url): route export to an OTLP HTTP endpoint (e.g. an OTel Collector) instead of the agent. - setOtlpProtocol('http/json'|'http/protobuf'): select the wire format; rejects unsupported values (grpc) at the parse boundary. - setOtlpHeaders([k, v, ...]): extra headers (e.g. collector auth). All three apply at lazy build time and only when an OTLP endpoint is set.
1 parent 011fae8 commit 1d566bc

2 files changed

Lines changed: 146 additions & 0 deletions

File tree

crates/pipeline/src/lib.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use libdd_data_pipeline::trace_exporter::agent_response::AgentResponse;
33
use libdd_data_pipeline::trace_exporter::{
44
TraceExporter, TraceExporterBuilder, TraceExporterOutputFormat,
55
};
6+
use libdd_data_pipeline::OtlpProtocol;
67
use libdd_shared_runtime::LocalRuntime;
78
use std::cell::{Cell, RefCell, UnsafeCell};
89
use std::ffi::CStr;
@@ -180,6 +181,18 @@ pub struct WasmSpanState {
180181
/// fixed once the exporter is built on the first send, so `setUseV05` only
181182
/// takes effect if called before then.
182183
use_v05: Cell<bool>,
184+
/// When set, the lazily-built exporter is configured to export traces via
185+
/// OTLP HTTP to this endpoint (e.g. an OTel Collector) INSTEAD of the
186+
/// Datadog agent. libdatadog maps its internal traces to OTLP, so no
187+
/// JS-formatted spans are involved. Like `use_v05`, only takes effect if
188+
/// set before the first send (when the exporter is built).
189+
otlp_endpoint: RefCell<Option<String>>,
190+
/// OTLP wire protocol (`http/json` default, or `http/protobuf`). Only
191+
/// applied when `otlp_endpoint` is set.
192+
otlp_protocol: Cell<Option<OtlpProtocol>>,
193+
/// Extra HTTP headers for OTLP export (e.g. collector auth), as key/value
194+
/// pairs. Only applied when `otlp_endpoint` is set.
195+
otlp_headers: RefCell<Vec<(String, String)>>,
183196
}
184197

185198
/// Clears an in-flight flag on drop, so an early return or a dropped future
@@ -269,6 +282,9 @@ impl WasmSpanState {
269282
prepared_spans: RefCell::new(None),
270283
sending: Cell::new(false),
271284
use_v05: Cell::new(false),
285+
otlp_endpoint: RefCell::new(None),
286+
otlp_protocol: Cell::new(None),
287+
otlp_headers: RefCell::new(Vec::new()),
272288
})
273289
}
274290

@@ -284,6 +300,36 @@ impl WasmSpanState {
284300
self.use_v05.set(v);
285301
}
286302

303+
/// Route trace export through libdatadog's OTLP HTTP exporter to `url`
304+
/// instead of the Datadog agent. Must be called before the first send.
305+
#[wasm_bindgen(js_name = "setOtlpEndpoint")]
306+
pub fn set_otlp_endpoint(&self, url: String) {
307+
*self.otlp_endpoint.borrow_mut() = Some(url);
308+
}
309+
310+
/// Select the OTLP wire protocol: `http/json` (default) or `http/protobuf`.
311+
/// Rejects unsupported values (e.g. `grpc`). Only takes effect with an OTLP
312+
/// endpoint set, before the first send.
313+
#[wasm_bindgen(js_name = "setOtlpProtocol")]
314+
pub fn set_otlp_protocol(&self, protocol: String) -> Result<(), JsValue> {
315+
let parsed = protocol
316+
.parse::<OtlpProtocol>()
317+
.map_err(|e| JsValue::from_str(&format!("setOtlpProtocol: {e}")))?;
318+
self.otlp_protocol.set(Some(parsed));
319+
Ok(())
320+
}
321+
322+
/// Set extra HTTP headers for OTLP export as a flat `[key, value, ...]`
323+
/// array. Only takes effect with an OTLP endpoint set, before the first send.
324+
#[wasm_bindgen(js_name = "setOtlpHeaders")]
325+
pub fn set_otlp_headers(&self, kv: Vec<String>) {
326+
let headers = kv
327+
.chunks_exact(2)
328+
.map(|pair| (pair[0].clone(), pair[1].clone()))
329+
.collect();
330+
*self.otlp_headers.borrow_mut() = headers;
331+
}
332+
287333
#[wasm_bindgen]
288334
pub fn change_queue_ptr(&self) -> *const u8 {
289335
self.change_queue.as_ptr()
@@ -408,6 +454,19 @@ impl WasmSpanState {
408454
if self.use_v05.get() {
409455
builder.set_output_format(TraceExporterOutputFormat::V05);
410456
}
457+
// When an OTLP endpoint is configured, libdatadog exports traces via
458+
// OTLP HTTP to that endpoint instead of the Datadog agent (mutually
459+
// exclusive with the agent v0.4/v0.5 path).
460+
if let Some(url) = self.otlp_endpoint.borrow().as_deref() {
461+
builder.set_otlp_endpoint(url);
462+
if let Some(protocol) = self.otlp_protocol.get() {
463+
builder.set_otlp_protocol(protocol);
464+
}
465+
let headers = self.otlp_headers.borrow();
466+
if !headers.is_empty() {
467+
builder.set_otlp_headers(headers.clone());
468+
}
469+
}
411470
let built = builder
412471
.build_async::<WasmCapabilities>()
413472
.await

test/pipeline.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,93 @@ describe('pipeline', () => {
827827
assert.ok(req, 'agent received a POST')
828828
assert.strictEqual(req.url, '/v0.5/traces')
829829
})
830+
831+
it('exports via OTLP HTTP after setOtlpEndpoint(url)', async () => {
832+
// libdatadog maps its internal traces to OTLP and POSTs them to the
833+
// configured endpoint instead of the Datadog agent. Confirms the OTLP
834+
// path runs end-to-end over the wasm HTTP transport.
835+
const http = require('node:http')
836+
const seen = []
837+
const server = http.createServer((req, res) => {
838+
const chunks = []
839+
req.on('data', c => chunks.push(c))
840+
req.on('end', () => {
841+
seen.push({
842+
method: req.method,
843+
url: req.url,
844+
ct: req.headers['content-type'],
845+
len: Buffer.concat(chunks).length
846+
})
847+
res.writeHead(200, { 'content-type': 'application/json' })
848+
res.end('{}')
849+
})
850+
})
851+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
852+
const { port } = server.address()
853+
const ns = new NativeSpansInterface({ agentUrl: `http://127.0.0.1:${port}` })
854+
ns.state.setOtlpEndpoint(`http://127.0.0.1:${port}/v1/traces`)
855+
const span = ns.createSpan()
856+
span.name = 'otlp-span'
857+
span.service = 'test-service'
858+
span.resource = 'test-resource'
859+
span.type = 'web'
860+
span.duration = 1000000n
861+
try {
862+
await ns.flushSpans(span)
863+
const req = seen.find(r => r.method === 'POST')
864+
assert.ok(req, 'OTLP endpoint received a POST')
865+
assert.strictEqual(req.url, '/v1/traces')
866+
assert.match(req.ct || '', /json|protobuf/)
867+
assert.ok(req.len > 0, 'OTLP body is non-empty')
868+
} finally {
869+
server.closeAllConnections?.()
870+
server.close()
871+
}
872+
})
873+
874+
it('honors setOtlpProtocol(http/protobuf) and setOtlpHeaders', async () => {
875+
const http = require('node:http')
876+
let captured
877+
const server = http.createServer((req, res) => {
878+
req.on('data', () => {})
879+
req.on('end', () => {
880+
if (req.method === 'POST') {
881+
captured = {
882+
ct: req.headers['content-type'],
883+
auth: req.headers.authorization
884+
}
885+
}
886+
res.writeHead(200, { 'content-type': 'application/json' })
887+
res.end('{}')
888+
})
889+
})
890+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
891+
const { port } = server.address()
892+
const ns = new NativeSpansInterface({ agentUrl: `http://127.0.0.1:${port}` })
893+
ns.state.setOtlpEndpoint(`http://127.0.0.1:${port}/v1/traces`)
894+
ns.state.setOtlpProtocol('http/protobuf')
895+
ns.state.setOtlpHeaders(['authorization', 'Bearer test-token'])
896+
const span = ns.createSpan()
897+
span.name = 'otlp-span'
898+
span.service = 'test-service'
899+
span.resource = 'test-resource'
900+
span.type = 'web'
901+
span.duration = 1000000n
902+
try {
903+
await ns.flushSpans(span)
904+
assert.ok(captured, 'OTLP endpoint received a POST')
905+
assert.match(captured.ct || '', /protobuf/)
906+
assert.strictEqual(captured.auth, 'Bearer test-token')
907+
} finally {
908+
server.closeAllConnections?.()
909+
server.close()
910+
}
911+
})
912+
913+
it('rejects unsupported OTLP protocols (e.g. grpc)', () => {
914+
const ns = new NativeSpansInterface({ agentUrl: 'http://127.0.0.1:8126' })
915+
assert.throws(() => ns.state.setOtlpProtocol('grpc'), /setOtlpProtocol|not supported/)
916+
})
830917
})
831918

832919
describe('client-side stats', () => {

0 commit comments

Comments
 (0)