Skip to content
Draft
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
8 changes: 8 additions & 0 deletions integration-tests/helpers/fake-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,14 @@ function buildExpressServer (agent) {
})
})

app.post('/v1/traces', (req, res) => {
res.status(200).send()
agent.emit('otlp-traces', {
headers: req.headers,
payload: req.body,
})
})

app.post('/evp_proxy/v2/api/v2/exposures', (req, res) => {
res.status(200).send()
agent.emit('exposures', {
Expand Down
153 changes: 153 additions & 0 deletions integration-tests/opentelemetry-traces.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'use strict'

const assert = require('node:assert/strict')

const { fork } = require('child_process')
const { join } = require('path')
const { FakeAgent, sandboxCwd, useSandbox } = require('./helpers')

function waitForOtlpTraces (agent, timeout = 10000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Timeout waiting for OTLP traces')), timeout)
agent.once('otlp-traces', (msg) => {
clearTimeout(timer)
resolve(msg)
})
})
}

describe('OTLP Trace Export', () => {
let agent
let cwd
const timeout = 10000

useSandbox()

before(async () => {
cwd = sandboxCwd()
agent = await new FakeAgent().start()
})

after(async () => {
await agent.stop()
})

it('should export traces in OTLP JSON format', async () => {
const tracesPromise = waitForOtlpTraces(agent, timeout)

const proc = fork(join(cwd, 'opentelemetry/otlp-traces.js'), {
cwd,
env: {
DD_TRACE_AGENT_PORT: agent.port,
OTEL_TRACES_EXPORTER: 'otlp',
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: `http://127.0.0.1:${agent.port}/v1/traces`,
DD_SERVICE: 'otlp-test-service',
DD_ENV: 'test',
DD_VERSION: '1.0.0',
},
})

const exitPromise = new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Process timed out')), timeout)
proc.on('error', reject)
proc.on('exit', (code) => {
clearTimeout(timer)
if (code !== 0) {
reject(new Error(`Process exited with status code ${code}`))
} else {
resolve()
}
})
})

const { headers, payload } = await tracesPromise
await exitPromise

assert.strictEqual(headers['content-type'], 'application/json')

// Validate ExportTraceServiceRequest top-level structure
assert.ok(payload.resourceSpans, 'payload should have resourceSpans')
assert.strictEqual(payload.resourceSpans.length, 1)

const resourceSpan = payload.resourceSpans[0]

// Validate resource attributes
const resource = resourceSpan.resource
assert.ok(resource, 'resourceSpan should have resource')
assert.ok(Array.isArray(resource.attributes), 'resource should have attributes array')

const resourceAttrs = Object.fromEntries(
resource.attributes.map(({ key, value }) => [key, value])
)
assert.deepStrictEqual(resourceAttrs['service.name'], { stringValue: 'otlp-test-service' })
assert.deepStrictEqual(resourceAttrs['deployment.environment'], { stringValue: 'test' })
assert.deepStrictEqual(resourceAttrs['service.version'], { stringValue: '1.0.0' })

// Validate scopeSpans
assert.ok(Array.isArray(resourceSpan.scopeSpans), 'resourceSpan should have scopeSpans')
assert.strictEqual(resourceSpan.scopeSpans.length, 1)

const scopeSpan = resourceSpan.scopeSpans[0]
assert.strictEqual(scopeSpan.scope.name, 'dd-trace-js')
assert.ok(scopeSpan.scope.version, 'scope should have a version')

// Validate spans
const spans = scopeSpan.spans
assert.strictEqual(spans.length, 3, 'should have 3 spans')

// Sort by name for stable ordering
spans.sort((a, b) => a.name.localeCompare(b.name))

const [dbSpan, errSpan, webSpan] = spans

// All spans should share the same traceId
assert.deepStrictEqual(dbSpan.traceId, webSpan.traceId, 'all spans should share a traceId')
assert.deepStrictEqual(errSpan.traceId, webSpan.traceId, 'all spans should share a traceId')

// Root span (web.request) should not have parentSpanId
assert.strictEqual(webSpan.parentSpanId, undefined, 'root span should not have parentSpanId')

// Child spans should have parentSpanId equal to root span's spanId
assert.deepStrictEqual(dbSpan.parentSpanId, webSpan.spanId, 'child span should reference parent')
assert.deepStrictEqual(errSpan.parentSpanId, webSpan.spanId, 'error span should reference parent')

// Validate span names
assert.strictEqual(webSpan.name, 'web.request')
assert.strictEqual(dbSpan.name, 'db.query')
assert.strictEqual(errSpan.name, 'error.operation')

// Validate span kind (server=2, client=3 per OTLP proto SpanKind enum)
assert.strictEqual(webSpan.kind, 2, 'web.request should be SERVER kind')
assert.strictEqual(dbSpan.kind, 3, 'db.query should be CLIENT kind')

// Validate timing fields
for (const span of spans) {
assert.ok(span.startTimeUnixNano > 0, 'span should have a positive startTimeUnixNano')
assert.ok(span.endTimeUnixNano > 0, 'span should have a positive endTimeUnixNano')
assert.ok(span.endTimeUnixNano >= span.startTimeUnixNano, 'endTime should be >= startTime')
}

// Validate error span status
assert.strictEqual(errSpan.status.code, 2, 'error span should have STATUS_CODE_ERROR')
assert.strictEqual(errSpan.status.message, 'test error message')

// Validate non-error span status
assert.strictEqual(webSpan.status.code, 0, 'non-error span should have STATUS_CODE_UNSET')

// Validate span attributes include service.name and resource.name
const webAttrs = Object.fromEntries(
webSpan.attributes.map(({ key, value }) => [key, value])
)
assert.deepStrictEqual(webAttrs['service.name'], { stringValue: 'otlp-test-service' })
assert.ok(webAttrs['resource.name'], 'span should have resource.name attribute')

// Validate custom tags appear as attributes
assert.deepStrictEqual(webAttrs['http.method'], { stringValue: 'GET' })
assert.deepStrictEqual(webAttrs['http.url'], { stringValue: '/api/test' })

const dbAttrs = Object.fromEntries(
dbSpan.attributes.map(({ key, value }) => [key, value])
)
assert.deepStrictEqual(dbAttrs['db.type'], { stringValue: 'postgres' })
})
})
25 changes: 25 additions & 0 deletions integration-tests/opentelemetry/otlp-traces.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use strict'

const tracer = require('dd-trace').init()

const rootSpan = tracer.startSpan('web.request', {
tags: { 'span.kind': 'server', 'http.method': 'GET', 'http.url': '/api/test' },
})

const childSpan = tracer.startSpan('db.query', {
childOf: rootSpan,
tags: { 'span.kind': 'client', 'db.type': 'postgres' },
})
childSpan.finish()

const errorSpan = tracer.startSpan('error.operation', {
childOf: rootSpan,
})
errorSpan.setTag('error', true)
errorSpan.setTag('error.message', 'test error message')
errorSpan.finish()

rootSpan.finish()

// Allow time for the HTTP export request to complete
setTimeout(() => process.exit(0), 1500)
6 changes: 6 additions & 0 deletions packages/dd-trace/src/config/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ module.exports = {
otelMetricsHeaders: '',
otelMetricsProtocol: 'http/protobuf',
otelMetricsTimeout: 10_000,
otelTracesEnabled: false,
Copy link
Collaborator Author

@ida613 ida613 Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this config var is not exactly in the requirement doc. This was added as the counterpart to config.otelMetricsEnabled and config.otelLogsEnabled, and is equals to OTEL_TRACES_EXPORTER==otlp

otelTracesSampler: 'parentbased_always_on',
otelTracesUrl: undefined, // Will be computed using agent host
otelTracesHeaders: '',
otelTracesProtocol: 'http/json',
otelTracesTimeout: 10_000,
otelMetricsExportTimeout: 7500,
otelMetricsExportInterval: 10_000,
otelMetricsTemporalityPreference: 'DELTA', // DELTA, CUMULATIVE, or LOWMEMORY
Expand Down
30 changes: 25 additions & 5 deletions packages/dd-trace/src/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const OTEL_DD_ENV_MAPPING = new Map([
const VALID_PROPAGATION_STYLES = new Set(['datadog', 'tracecontext', 'b3', 'b3 single header', 'none'])
const VALID_PROPAGATION_BEHAVIOR_EXTRACT = new Set(['continue', 'restart', 'ignore'])
const VALID_LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error'])
const DEFAULT_OTLP_PORT = 4318
const DEFAULT_OTLP_HTTP_PORT = 4318
const RUNTIME_ID = uuid()
const NAMING_VERSIONS = new Set(['v0', 'v1'])
const DEFAULT_NAMING_VERSION = 'v0'
Expand Down Expand Up @@ -419,6 +419,11 @@ class Config {
OTEL_EXPORTER_OTLP_METRICS_PROTOCOL,
OTEL_EXPORTER_OTLP_METRICS_TIMEOUT,
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE,
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
OTEL_EXPORTER_OTLP_TRACES_HEADERS,
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL,
OTEL_EXPORTER_OTLP_TRACES_TIMEOUT,
OTEL_TRACES_EXPORTER,
OTEL_METRIC_EXPORT_TIMEOUT,
OTEL_EXPORTER_OTLP_PROTOCOL,
OTEL_EXPORTER_OTLP_ENDPOINT,
Expand Down Expand Up @@ -493,6 +498,21 @@ class Config {
setString(target, 'otelMetricsTemporalityPreference', temporalityPref)
}
}
if (OTEL_TRACES_EXPORTER) {
setBoolean(target, 'otelTracesEnabled', OTEL_TRACES_EXPORTER.toLowerCase() === 'otlp')
}
if (OTEL_TRACES_SAMPLER) {
setString(target, 'otelTracesSampler', OTEL_TRACES_SAMPLER.toLowerCase())
}
// Set OpenTelemetry traces configuration with specific _TRACES_ vars
// taking precedence over generic _EXPORTERS_ vars
if (OTEL_EXPORTER_OTLP_ENDPOINT || OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) {
setString(target, 'otelTracesUrl', OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || target.otelUrl)
}
setString(target, 'otelTracesHeaders', OTEL_EXPORTER_OTLP_TRACES_HEADERS || target.otelHeaders)
setString(target, 'otelTracesProtocol', OTEL_EXPORTER_OTLP_TRACES_PROTOCOL || target.otelProtocol)
const otelTracesTimeout = nonNegInt(OTEL_EXPORTER_OTLP_TRACES_TIMEOUT, 'OTEL_EXPORTER_OTLP_TRACES_TIMEOUT')
target.otelTracesTimeout = otelTracesTimeout === undefined ? target.otelTimeout : otelTracesTimeout
setBoolean(
target,
'apmTracingEnabled',
Expand Down Expand Up @@ -1120,9 +1140,10 @@ class Config {

// Compute OTLP logs and metrics URLs to send payloads to the active Datadog Agent
const agentHostname = this.#getHostname()
calc.otelLogsUrl = `http://${agentHostname}:${DEFAULT_OTLP_PORT}`
calc.otelMetricsUrl = `http://${agentHostname}:${DEFAULT_OTLP_PORT}/v1/metrics`
calc.otelUrl = `http://${agentHostname}:${DEFAULT_OTLP_PORT}`
calc.otelLogsUrl = `http://${agentHostname}:${DEFAULT_OTLP_HTTP_PORT}`
calc.otelMetricsUrl = `http://${agentHostname}:${DEFAULT_OTLP_HTTP_PORT}/v1/metrics`
calc.otelTracesUrl = `http://${agentHostname}:${DEFAULT_OTLP_HTTP_PORT}/v1/traces`
calc.otelUrl = `http://${agentHostname}:${DEFAULT_OTLP_HTTP_PORT}`

setBoolean(calc, 'isGitUploadEnabled',
calc.isIntelligentTestRunnerEnabled && !isFalse(getEnv('DD_CIVISIBILITY_GIT_UPLOAD_ENABLED')))
Expand Down Expand Up @@ -1336,7 +1357,6 @@ function isInvalidOtelEnvironmentVariable (envVar, value) {
return Number.isNaN(Number.parseFloat(value))
case 'OTEL_SDK_DISABLED':
return value.toLowerCase() !== 'true' && value.toLowerCase() !== 'false'
case 'OTEL_TRACES_EXPORTER':
case 'OTEL_METRICS_EXPORTER':
case 'OTEL_LOGS_EXPORTER':
return value.toLowerCase() !== 'none'
Expand Down
4 changes: 4 additions & 0 deletions packages/dd-trace/src/config/supported-configurations.json
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@
"OTEL_EXPORTER_OTLP_METRICS_HEADERS": ["A"],
"OTEL_EXPORTER_OTLP_METRICS_PROTOCOL": ["A"],
"OTEL_EXPORTER_OTLP_METRICS_TIMEOUT": ["A"],
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": ["A"],
"OTEL_EXPORTER_OTLP_TRACES_HEADERS": ["A"],
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL": ["A"],
"OTEL_EXPORTER_OTLP_TRACES_TIMEOUT": ["A"],
"OTEL_METRIC_EXPORT_INTERVAL": ["A"],
"OTEL_METRIC_EXPORT_TIMEOUT": ["A"],
"OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE": ["A"],
Expand Down
16 changes: 14 additions & 2 deletions packages/dd-trace/src/opentelemetry/otlp/protobuf_loader.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
'use strict'

/**
* Protobuf Loader for OpenTelemetry Logs and Metrics
* Protobuf Loader for OpenTelemetry Logs, Traces, and Metrics
*
* This module loads protobuf definitions for OpenTelemetry logs and metrics.
* This module loads protobuf definitions for OpenTelemetry logs, traces, and metrics.
*
* VERSION SUPPORT:
* - OTLP Protocol: v1.7.0
Expand All @@ -20,6 +20,8 @@ const protobuf = require('../../../../../vendor/dist/protobufjs')
let _root = null
let protoLogsService = null
let protoSeverityNumber = null
let protoTraceService = null
let protoSpanKind = null
let protoMetricsService = null
let protoAggregationTemporality = null

Expand All @@ -28,6 +30,8 @@ function getProtobufTypes () {
return {
protoLogsService,
protoSeverityNumber,
protoTraceService,
protoSpanKind,
protoMetricsService,
protoAggregationTemporality,
}
Expand All @@ -39,6 +43,8 @@ function getProtobufTypes () {
'resource.proto',
'logs.proto',
'logs_service.proto',
'trace.proto',
'trace_service.proto',
'metrics.proto',
'metrics_service.proto',
].map(file => path.join(protoDir, file))
Expand All @@ -49,13 +55,19 @@ function getProtobufTypes () {
protoLogsService = _root.lookupType('opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest')
protoSeverityNumber = _root.lookupEnum('opentelemetry.proto.logs.v1.SeverityNumber')

// Get the message types for traces
protoTraceService = _root.lookupType('opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest')
protoSpanKind = _root.lookupEnum('opentelemetry.proto.trace.v1.SpanKind')

// Get the message types for metrics
protoMetricsService = _root.lookupType('opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest')
protoAggregationTemporality = _root.lookupEnum('opentelemetry.proto.metrics.v1.AggregationTemporality')

return {
protoLogsService,
protoSeverityNumber,
protoTraceService,
protoSpanKind,
protoMetricsService,
protoAggregationTemporality,
}
Expand Down
Loading
Loading