dd-trace
Version:
Datadog APM tracing client for JavaScript
277 lines (246 loc) • 10.6 kB
JavaScript
'use strict'
const { extractPathFromUrl } = require('./url')
// OpenTelemetry HTTP semantic-convention attribute names, emitted in place of
// the Datadog ones when `DD_TRACE_OTEL_SEMANTICS_ENABLED` is set.
// See https://opentelemetry.io/docs/specs/semconv/http/http-spans/
const HTTP_REQUEST_METHOD = 'http.request.method'
const HTTP_RESPONSE_STATUS_CODE = 'http.response.status_code'
const URL_FULL = 'url.full'
const URL_PATH = 'url.path'
const URL_SCHEME = 'url.scheme'
const URL_QUERY = 'url.query'
const SERVER_ADDRESS = 'server.address'
const SERVER_PORT = 'server.port'
const USER_AGENT_ORIGINAL = 'user_agent.original'
const CLIENT_ADDRESS = 'client.address'
const NETWORK_PEER_ADDRESS = 'network.peer.address'
const HTTP_REQUEST_METHOD_ORIGINAL = 'http.request.method_original'
// Known HTTP methods (RFC 9110 + PATCH RFC 5789 + QUERY httpbis draft). A verb
// outside this set is reported as `_OTHER` with the raw value preserved on
// `http.request.method_original`, per the OTel HTTP semantic conventions.
const KNOWN_METHODS = new Set([
'CONNECT', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'QUERY', 'TRACE',
])
// Datadog HTTP meta keys replaced by OTel names — omitted when rebuilding meta.
const DD_HTTP_META_KEYS = new Set([
'http.method', 'http.status_code', 'http.useragent', 'http.client_ip', 'http.endpoint', 'http.url', 'out.host',
])
const NETWORK_DESTINATION_PORT = 'network.destination.port'
// IPv6 literals arrive bracketed (URL.hostname / out.host = `[::1]`); OTel
// `server.address` is the bare address.
function stripIpv6Brackets (host) {
return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
}
/**
* @typedef {object} ServerUrlParts
* @property {string} [scheme] value for `url.scheme`
* @property {string} [address] value for `server.address`
* @property {number} [port] value for `server.port`
* @property {string} path value for `url.path`
* @property {string} [query] value for `url.query` (omitted when empty)
*/
/**
* Decompose a server request URL into the OpenTelemetry `url.*` / `server.*`
* parts. Structural fields (scheme, address, port, path) are read from the raw
* URL; the query is taken from the already-obfuscated URL so the configured
* query-string obfuscation is preserved.
*
* @param {string} rawUrl full request URL (`scheme://host[:port]/path?query`)
* @param {string} obfuscatedUrl same URL with its query string obfuscated
* @returns {ServerUrlParts}
*/
function decomposeServerUrl (rawUrl, obfuscatedUrl) {
let scheme
let address
let port
let path
try {
const parsed = new URL(rawUrl)
scheme = parsed.protocol.length > 1 ? parsed.protocol.slice(0, -1) : undefined
// `extractURL` builds `http://undefined/...` when the Host header is absent; skip that.
const hostname = parsed.hostname
if (hostname && hostname !== 'undefined') {
address = stripIpv6Brackets(hostname)
}
if (parsed.port) {
const parsedPort = Number.parseInt(parsed.port)
if (parsedPort > 0) port = parsedPort
}
path = parsed.pathname || '/'
} catch {
// Malformed or relative URL: fall back to a best-effort path only.
path = extractPathFromUrl(rawUrl)
}
let query
const queryIndex = obfuscatedUrl.indexOf('?')
if (queryIndex !== -1) {
const rawQuery = obfuscatedUrl.slice(queryIndex + 1)
if (rawQuery) query = rawQuery
}
return { scheme, address, port, path, query }
}
const ERROR_TYPE = 'error.type'
function toHttpScheme (scheme) {
if (scheme === 'ws') return 'http'
if (scheme === 'wss') return 'https'
return scheme
}
/**
* Redact any userinfo embedded in a URL's authority, since `url.full` must not
* leak credentials: `user:pass@host` -> `REDACTED:REDACTED@host`, `user@host` ->
* `REDACTED@host`. Returns the URL unchanged when no userinfo is present.
*
* @param {string} url
* @returns {string}
*/
function redactUrlCredentials (url) {
const schemeEnd = url.indexOf('://')
if (schemeEnd === -1) return url
const authorityStart = schemeEnd + 3
let authorityEnd = url.length
for (let i = authorityStart; i < url.length; i++) {
const char = url[i]
if (char === '/' || char === '?' || char === '#') {
authorityEnd = i
break
}
}
// userinfo runs to the LAST '@' in the authority (WHATWG); using the first
// '@' would leak the remainder, e.g. `user:p@ss@host`.
const at = url.lastIndexOf('@', authorityEnd - 1)
if (at < authorityStart) return url
const redacted = url.slice(authorityStart, at).includes(':') ? 'REDACTED:REDACTED' : 'REDACTED'
return url.slice(0, authorityStart) + redacted + url.slice(at)
}
/**
* The scheme's default port, used as the `server.port` fallback for client spans
* (the attribute is required for clients but the explicit port is absent for
* default-port requests).
*
* @param {string} [url]
* @returns {number | undefined}
*/
function defaultPortForUrl (url) {
if (url === undefined) return
if (url.startsWith('https:') || url.startsWith('wss:')) return 443
if (url.startsWith('http:') || url.startsWith('ws:')) return 80
}
/**
* @typedef {object} FormattedHttpSpan
* @property {Record<string, string>} meta
* @property {Record<string, number>} metrics
* @property {number} error
*/
/**
* Rewrite a formatted span's Datadog HTTP tags to OpenTelemetry HTTP
* semantic-convention names, in place. Called at serialization time (from
* `span_format`) when `DD_TRACE_OTEL_SEMANTICS_ENABLED` is set, so every HTTP
* integration is covered from one place. No-op for non-HTTP spans. The span
* keeps the Datadog tag names throughout its lifetime — only the serialized
* output is renamed — so runtime consumers (peer.service, AppSec, trace stats)
* are unaffected.
*
* @param {FormattedHttpSpan} formattedSpan
*/
function applyHttpOtelSemantics (formattedSpan) {
const meta = formattedSpan.meta
const metrics = formattedSpan.metrics
const method = meta['http.method']
const url = meta['http.url']
if (method === undefined && url === undefined) return
// Rebuild meta/metrics as fresh objects that omit the renamed Datadog HTTP
// keys. Deleting them in place demotes the formatted span to V8 dictionary
// mode (~40% slower than this rebuild, measured); a fresh object keeps fast
// properties and can't leak a renamed key as `undefined` on the OTLP path.
const newMeta = {}
for (const key of Object.keys(meta)) {
if (!DD_HTTP_META_KEYS.has(key)) newMeta[key] = meta[key]
}
const newMetrics = {}
for (const key of Object.keys(metrics)) {
if (key !== NETWORK_DESTINATION_PORT) newMetrics[key] = metrics[key]
}
const kind = meta['span.kind']
if (method !== undefined) {
if (KNOWN_METHODS.has(method)) {
newMeta[HTTP_REQUEST_METHOD] = method
} else {
// Unknown verb: bucket to `_OTHER`, preserve the raw value, and use the
// literal "HTTP" in the span name (the spec forbids the URL path there).
// Known-method names are already `{method} {route}`.
newMeta[HTTP_REQUEST_METHOD] = '_OTHER'
newMeta[HTTP_REQUEST_METHOD_ORIGINAL] = method
const resource = formattedSpan.resource
if (typeof resource === 'string') {
if (resource === method) {
formattedSpan.resource = 'HTTP'
} else if (resource.startsWith(`${method} `)) {
formattedSpan.resource = `HTTP${resource.slice(method.length)}`
}
}
}
}
const status = meta['http.status_code']
let statusCode
if (status !== undefined) {
// OTel types http.response.status_code as an int, so emit it as a numeric
// metric (the OTLP exporter serializes meta as stringValue but metrics as
// intValue) — mirroring how server.port is handled below. Guard against a
// non-numeric status, which would otherwise write a NaN metric.
statusCode = Number.parseInt(status)
if (Number.isFinite(statusCode)) newMetrics[HTTP_RESPONSE_STATUS_CODE] = statusCode
}
const userAgent = meta['http.useragent']
if (userAgent !== undefined) newMeta[USER_AGENT_ORIGINAL] = userAgent
const clientIp = meta['http.client_ip']
if (clientIp !== undefined) newMeta[CLIENT_ADDRESS] = clientIp
// http.endpoint is Datadog-only (omitted above); it has no OTel equivalent.
if (kind === 'server') {
// FIXME: some server frameworks (e.g. Next.js — `packages/datadog-plugin-next`)
// never populate `http.url`, so the OTel `url.*` / `server.*` attributes below
// can't be derived and are omitted for those spans. This needs a fix; the
// short-term option is to set `http.url` in those integrations so they emit
// the full server attribute set.
if (url !== undefined) {
// The query in `http.url` is already obfuscated per config, so it is preserved.
const { scheme, address, port, path, query } = decomposeServerUrl(url, url)
if (path !== undefined) newMeta[URL_PATH] = path
if (scheme !== undefined) newMeta[URL_SCHEME] = toHttpScheme(scheme)
if (query !== undefined) newMeta[URL_QUERY] = query
if (address !== undefined) newMeta[SERVER_ADDRESS] = address
if (port !== undefined) newMetrics[SERVER_PORT] = port
}
} else {
if (url !== undefined) {
// url.full must not carry embedded credentials.
newMeta[URL_FULL] = redactUrlCredentials(url)
}
const outHost = meta['out.host']
if (outHost !== undefined) newMeta[SERVER_ADDRESS] = stripIpv6Brackets(outHost)
const clientPort = metrics[NETWORK_DESTINATION_PORT]
if (clientPort === undefined) {
// server.port is required for client spans; fall back to the scheme default.
const defaultPort = defaultPortForUrl(url)
if (defaultPort !== undefined) newMetrics[SERVER_PORT] = defaultPort
} else {
newMetrics[SERVER_PORT] = clientPort
}
}
// OTel error semantics for an error response (no-clobber on an exception-derived
// type): server spans are errors on 5xx only (4xx MUST be left unset per the
// spec); client spans on any status >= 400.
if (status !== undefined && newMeta[ERROR_TYPE] === undefined) {
const isError = kind === 'server' ? statusCode >= 500 : statusCode >= 400
if (isError) {
newMeta[ERROR_TYPE] = status
formattedSpan.error = 1
}
}
formattedSpan.meta = newMeta
formattedSpan.metrics = newMetrics
}
module.exports = {
NETWORK_PEER_ADDRESS, // imported by web.js (set from req.socket, not at serialization)
decomposeServerUrl, // exercised directly by the helper spec
applyHttpOtelSemantics,
}