Skip to content
Merged
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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,12 @@ Services that use the same rule agree with each other: a trace kept at one
ratio is kept at any higher ratio. A `sampleRatio` of `0` starts no sampled
traces, so only requests that arrive sampled are traced.

A span whose trace is not sampled is never exported, so it records none of
what only the export would read: no attributes, no timestamps, no response
listeners. Trace and span IDs, trace flags, tracestate, and baggage are kept,
the span is still the parent of the spans made while handling the request,
and its context is still injected into outgoing requests.

When `sampleRatio` is below `1`, a sampled root span starts its trace with the
`tracestate` entry `ot=th:<threshold>`. It tells samplers and backends
downstream the probability the trace was kept with. Traces this process starts
Expand All @@ -302,7 +308,9 @@ The traced and untraced HTTP and fetch configurations live in separate
files: the runner executes each file in its own child process, and tracing
activates at most once per process. The traced runs export to a local dummy
collector in the same process, so the measurement includes the export work
done while the requests run.
done while the requests run. An unsampled traced configuration, with
`sampleRatio: 0`, is measured too: its requests propagate context but never
export, so the comparison tracks what that costs.

Smaller benchmarks time pieces of that work on their own: building client
and server spans, generating IDs into a `traceparent` value, and exporting
Expand Down
8 changes: 8 additions & 0 deletions benchmark/fetch-unsampled.bench.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use strict';

const { registerFetchBenchmark } = require('./helpers/http');

registerFetchBenchmark('fetch request (traced, unsampled)', {
tracing: true,
sampleRatio: 0,
});
23 changes: 14 additions & 9 deletions benchmark/helpers/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,21 @@ const listen = (server) =>
const close = (server) => new Promise((resolve) => server.close(resolve));

// Starts, when tracing, a dummy collector: the measurement then covers
// span creation, serialization, and the export round trip.
async function startCollector({ tracing }) {
// span creation, serialization, and the export round trip. A sampleRatio
// of 0 traces requests without exporting anything, so it measures the
// work a request that only propagates its context pays for.
async function startCollector({ tracing, sampleRatio }) {
if (!tracing) return null;

const collector = http.createServer((req, res) => {
req.resume();
res.end();
});
await listen(collector);
microtel.init({ endpoint: `http://127.0.0.1:${collector.address().port}` });
microtel.init({
endpoint: `http://127.0.0.1:${collector.address().port}`,
sampleRatio,
});
return collector;
}

Expand All @@ -38,8 +43,8 @@ async function startTargetServer() {

// Sets up a local target server, a keep-alive agent for the load, and,
// when tracing, a dummy collector.
async function setup({ tracing = false } = {}) {
const collector = await startCollector({ tracing });
async function setup({ tracing = false, sampleRatio } = {}) {
const collector = await startCollector({ tracing, sampleRatio });
const server = await startTargetServer();

const port = server.address().port;
Expand Down Expand Up @@ -69,13 +74,13 @@ async function setup({ tracing = false } = {}) {
// keep-alive connection, so the traced/untraced difference reflects
// per-request work (two spans per request), not scheduling or connection
// noise.
function registerHttpBenchmark(name, { tracing = false } = {}) {
function registerHttpBenchmark(name, { tracing = false, sampleRatio } = {}) {
const kRequestsPerSample = 200;

let ctx;

before(async () => {
ctx = await setup({ tracing });
ctx = await setup({ tracing, sampleRatio });
});

after(() => ctx.close());
Expand All @@ -93,15 +98,15 @@ function registerHttpBenchmark(name, { tracing = false } = {}) {
// Requests are sequential over the pooled connection, so the
// traced/untraced difference reflects per-request work (two spans per
// request), not scheduling or connection noise.
function registerFetchBenchmark(name, { tracing = false } = {}) {
function registerFetchBenchmark(name, { tracing = false, sampleRatio } = {}) {
const kRequestsPerSample = 200;

let server;
let collector;
let url;

before(async () => {
collector = await startCollector({ tracing });
collector = await startCollector({ tracing, sampleRatio });
server = await startTargetServer();
url = `http://127.0.0.1:${server.address().port}/bench`;
});
Expand Down
8 changes: 8 additions & 0 deletions benchmark/http-unsampled.bench.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
'use strict';

const { registerHttpBenchmark } = require('./helpers/http');

registerHttpBenchmark('http request (traced, unsampled)', {
tracing: true,
sampleRatio: 0,
});
117 changes: 73 additions & 44 deletions lib/instrumentations.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,6 @@ function onHttpServerRequestStart({ request, response, socket }) {
const baggage = request.headers.baggage;

const method = request.method || 'GET';
const rawUrl = request.url || '/';
const qIdx = rawUrl.indexOf('?');
const path = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx);
// Per the OTel semantic conventions span names must be low cardinality.
// Core has no route concept, so only the method is used; the path lives
// in an attribute.
Expand All @@ -234,6 +231,20 @@ function onHttpServerRequestStart({ request, response, socket }) {
baggage,
});

// The span deliberately stays in the AsyncLocalStorage after the request
// ends, so work started while handling it still links to the server
// span as its parent.
spanStorage.enterWith(span);

// Everything from here on serves only the export, which an unsampled
// span never reaches: the attributes, the stash the response handlers
// read, and the listener that ends the span at the right moment.
if (!span.isSampled) return;

const rawUrl = request.url || '/';
const qIdx = rawUrl.indexOf('?');
const path = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx);

span.setAttribute('http.request.method', method);
span.setAttribute('url.path', path);
if (qIdx !== -1) span.setAttribute('url.query', rawUrl.slice(qIdx + 1));
Expand All @@ -254,11 +265,6 @@ function onHttpServerRequestStart({ request, response, socket }) {
// need a second AsyncLocalStorage lookup.
response[kSpan] = span;

// The span deliberately stays in the AsyncLocalStorage after the request
// ends, so work started while handling it still links to the server
// span as its parent.
spanStorage.enterWith(span);

// Response 'close' without 'finish' means the connection was terminated
// before the response completed. Request 'close' cannot be used for
// this: it also fires when the request stream is merely consumed, which
Expand Down Expand Up @@ -348,21 +354,25 @@ function onHttpClientRequestCreated({ request }) {
const method = request.method || 'GET';
const span = new Span(method, SPAN_KIND_CLIENT, { parent });

span.setAttribute('http.request.method', method);
// The attributes and the stash the response and error handlers read
// serve only the export, which an unsampled span never reaches.
if (span.isSampled) {
span.setAttribute('http.request.method', method);

const [hostPart, portPart] = splitAuthority(authority);
span.setAttribute('server.address', host || hostPart);
// A missing port means the scheme's default.
if (portPart == null) {
span.setAttribute('server.port', protocol === 'https:' ? 443 : 80);
} else {
const port = parsePort(portPart);
if (port !== undefined) span.setAttribute('server.port', port);
}
const [hostPart, portPart] = splitAuthority(authority);
span.setAttribute('server.address', host || hostPart);
// A missing port means the scheme's default.
if (portPart == null) {
span.setAttribute('server.port', protocol === 'https:' ? 443 : 80);
} else {
const port = parsePort(portPart);
if (port !== undefined) span.setAttribute('server.port', port);
}

span.setAttribute('url.full', `${protocol}//${authority}${path}`);
span.setAttribute('url.full', `${protocol}//${authority}${path}`);

request[kSpan] = span;
request[kSpan] = span;
}

// Node stores the headers inside the ClientRequest constructor when they
// are given as an array or include Expect, so setHeader would throw
Expand Down Expand Up @@ -431,9 +441,6 @@ function onHttp2ServerStreamStart({ stream, headers }) {
const tracestate = parent != null ? headers?.tracestate : undefined;

const method = headers?.[':method'] || 'GET';
const rawUrl = headers?.[':path'] || '/';
const qIdx = rawUrl.indexOf('?');
const path = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx);

const span = new Span(method, SPAN_KIND_SERVER, {
parent,
Expand All @@ -445,16 +452,34 @@ function onHttp2ServerStreamStart({ stream, headers }) {
baggage: headers.baggage,
});

// A stream whose session is already gone was destroyed by an earlier
// subscriber, before this channel fired. No handler code will run for
// it, so it is not made current in either sampling mode. An unsampled
// span is dropped here, since it is never exported.
const session = stream.session;
if (session === undefined && !span.isSampled) return;

// Everything from here on serves only the export, which an unsampled
// span never reaches: the attributes and the stash the stream handlers
// read. The span is still made current, so work started while handling
// the request links to it as its parent.
if (!span.isSampled) {
spanStorage.enterWith(span);
return;
}

const rawUrl = headers?.[':path'] || '/';
const qIdx = rawUrl.indexOf('?');
const path = qIdx === -1 ? rawUrl : rawUrl.slice(0, qIdx);

span.setAttribute('http.request.method', method);
span.setAttribute('url.path', path);
if (qIdx !== -1) span.setAttribute('url.query', rawUrl.slice(qIdx + 1));

// The :scheme pseudo-header carries the request scheme; requests that
// omit it fall back to the session's encryption state, like the
// socket check for HTTP/1.1. Another subscriber to the stream's
// created channel can destroy the stream before this channel fires,
// which clears the session, so it may be gone here.
const session = stream.session;
// socket check for HTTP/1.1. The session is gone for a stream
// destroyed before this channel fires, so it may be missing here.
const scheme =
headers?.[':scheme'] || (session?.encrypted ? 'https' : 'http');
span.setAttribute('url.scheme', scheme);
Expand Down Expand Up @@ -585,25 +610,29 @@ function onUndiciRequestCreate({ request }) {
const method = request.method || 'GET';
const span = new Span(method, SPAN_KIND_CLIENT, { parent });

span.setAttribute('http.request.method', method);

const { origin } = request;
const path = request.path;
if (origin) {
if (origin !== lastOrigin) {
// The origin omits the port when it is the scheme default.
const parsed = new URL(origin);
lastOriginAddress = parsed.hostname;
lastOriginPort =
Number(parsed.port) || (parsed.protocol === 'https:' ? 443 : 80);
lastOrigin = origin;
// The attributes and the stash the response handlers read serve only
// the export, which an unsampled span never reaches.
if (span.isSampled) {
span.setAttribute('http.request.method', method);

const { origin } = request;
const path = request.path;
if (origin) {
if (origin !== lastOrigin) {
// The origin omits the port when it is the scheme default.
const parsed = new URL(origin);
lastOriginAddress = parsed.hostname;
lastOriginPort =
Number(parsed.port) || (parsed.protocol === 'https:' ? 443 : 80);
lastOrigin = origin;
}
span.setAttribute('server.address', lastOriginAddress);
span.setAttribute('server.port', lastOriginPort);
}
span.setAttribute('server.address', lastOriginAddress);
span.setAttribute('server.port', lastOriginPort);
}
span.setAttribute('url.full', `${origin}${path}`);
span.setAttribute('url.full', `${origin}${path}`);

request[kSpan] = span;
request[kSpan] = span;
}

if (request.addHeader) {
setUndiciHeader(request, 'traceparent', span.toTraceparent());
Expand Down
35 changes: 28 additions & 7 deletions lib/span.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ class Span {
// clock; the end is derived as start + a monotonic duration, so spans
// survive a backward clock step while keeping human-readable absolute
// timestamps. Conversion to OTLP nanoseconds happens only at export, so
// unsampled or filtered spans never pay for it.
// unsampled or filtered spans never pay for it. An unsampled span is
// never exported, so it is never given timestamps: its endTime only
// marks that it has ended.
startTime;
endTime;
#startMonotonic;
Expand All @@ -126,11 +128,8 @@ class Span {
this.name = name;
this.kind = kind;
this.spanId = generateSpanId();
this.#attributes = new SpanAttributes();
this.#events = kNoEvents;
this.#status = kUnsetStatus;
this.startTime = Date.now();
this.#startMonotonic = performance.now();

let rootTracestate;
if (parent != null) {
Expand Down Expand Up @@ -159,17 +158,31 @@ class Span {
// Baggage follows the same inheritance: an explicit inbound value
// wins, children inherit their parent's.
this.baggage = options?.baggage ?? parent?.baggage;

// Attributes and timestamps serve only the export, so an unsampled
// span is given neither.
if (this.#traceFlags & kTraceFlagSampled) {
this.#attributes = new SpanAttributes();
this.startTime = Date.now();
this.#startMonotonic = performance.now();
}
}

get traceFlags() {
return this.#traceFlags;
}

get isSampled() {
return (this.#traceFlags & kTraceFlagSampled) !== 0;
}

get hasRemoteParent() {
return this.#remoteParent;
}

setAttribute(key, value) {
// An unsampled span has no attributes to record into.
if (this.#attributes === undefined) return this;
const writes = this.#attributeLimit;
if (typeof writes === 'number' && writes < kAttributeCountLimit) {
this.#attributeLimit = writes + 1;
Expand Down Expand Up @@ -220,6 +233,8 @@ class Span {
// Only the internal exception producer uses events today; cap them
// once addEvent becomes part of a public API.
// TODO(bengl): cap the number of events per span then.
// An unsampled span is never exported, so its events are dropped.
if (this.#attributes === undefined) return this;
if (this.#events === kNoEvents) this.#events = [];
this.#events.push({
name,
Expand All @@ -245,10 +260,16 @@ class Span {

end() {
if (this.endTime !== undefined) return;
this.endTime = this.startTime + (performance.now() - this.#startMonotonic);

// Only export sampled spans.
if (this.#traceFlags & kTraceFlagSampled) addSpan(this);
if (this.#traceFlags & kTraceFlagSampled) {
this.endTime =
this.startTime + (performance.now() - this.#startMonotonic);
addSpan(this);
} else {
// An unsampled span reads no clock on the way out, it is only
// marked ended.
this.endTime = 0;
}
}

// Produces the outgoing header value; the caller writes it into the
Expand Down
Loading
Loading