Skip to content
Open
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
63 changes: 49 additions & 14 deletions resources/prosody-plugins/mod_trace.lua
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,61 @@ local function find_tag(tags, name)
return nil
end

module:hook("iq/full", function(event)
local iq_element = event.stanza.tags[1]
if not iq_element then
-- Records a hop span for a stanza carrying a <traceparent/> child element
-- and rewrites its parent_id so the receiving service parents under this
-- span.
local function trace_element(span_name, traceparent)
local span = tracer:start_span(span_name, {
trace_id = traceparent.attr.trace_id,
span_id = traceparent.attr.parent_id,
})
span:end_span()

traceparent.attr.parent_id = span.span_id
end

-- Records a hop span for a Rayo dial IQ. The trace context arrives either
-- as a <traceparent/> element (as on colibri IQs), or as an X-Traceparent
-- Rayo header holding a W3C trace context value
-- ("00-<trace-id>-<parent-id>-<flags>"), which is the form jigasi consumes.
local function trace_dial(dial)
local traceparent = find_tag(dial.tags, "traceparent")
if traceparent then
trace_element("rayo.dial", traceparent)
return
end

if iq_element.name ~= "conference-modify" or iq_element.attr.xmlns ~= "jitsi:colibri2" then
return
for _, tag in ipairs(dial.tags) do
if tag.name == "header" and tag.attr.name == "X-Traceparent" then
local trace_id, parent_id, flags = string.match(
tag.attr.value or "", "^00%-(%x+)%-(%x+)%-(%x+)$")
if trace_id and #trace_id == 32 and #parent_id == 16 then
local span = tracer:start_span("rayo.dial", {
trace_id = trace_id,
span_id = parent_id,
})
span:end_span()

tag.attr.value =
"00-" .. trace_id .. "-" .. span.span_id .. "-" .. flags
end
return
end
end
end

local traceparent = find_tag(iq_element.tags, "traceparent")
if not traceparent then
module:hook("iq/full", function(event)
local iq_element = event.stanza.tags[1]
if not iq_element then
return
end

local span = tracer:start_span("colibri.conference-modify", {
trace_id = traceparent.attr.trace_id,
span_id = traceparent.attr.parent_id,
})
span:end_span()

traceparent.attr.parent_id = span.span_id
if iq_element.name == "conference-modify" and iq_element.attr.xmlns == "jitsi:colibri2" then
local traceparent = find_tag(iq_element.tags, "traceparent")
if traceparent then
trace_element("colibri.conference-modify", traceparent)
end
elseif iq_element.name == "dial" and iq_element.attr.xmlns == "urn:xmpp:rayo:1" then
trace_dial(iq_element)
end
end, 1)
41 changes: 41 additions & 0 deletions tests/prosody/helpers/xmpp_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,47 @@ export async function createXmppClient({ host = 'localhost', domain, params, use
);
},

/**
* Sends a Rayo dial IQ to the given full JID, optionally carrying a
* <traceparent> extension (as jicofo does when tracing is enabled) and/or
* an X-Traceparent Rayo header (the form that survives jigasi's
* RayoIqProvider parsing). Fire-and-forget — mod_trace does not reply;
* assert on the recipient's own stanza queue (waitForIq) and/or the OTLP
* mock receiver.
*
* @param {string} to Destination full JID.
* @param {object} [opts]
* @param {string} [opts.traceId] 32-hex-char trace id for a <traceparent> child.
* Omit to send no <traceparent>.
* @param {string} [opts.parentId] 16-hex-char parent span id. Required when traceId is set.
* @param {string} [opts.xTraceparentValue] Raw value for an X-Traceparent header. Omit to send no header.
*/
sendDialIq(to, { traceId, parentId, xTraceparentValue } = {}) {
const children = [];

if (traceId !== undefined) {
children.push(xml('traceparent', {
// eslint-disable-next-line camelcase
trace_id: traceId,
// eslint-disable-next-line camelcase
parent_id: parentId
}));
}

if (xTraceparentValue !== undefined) {
children.push(xml('header', { name: 'X-Traceparent',
value: xTraceparentValue }));
}

return xmpp.send(
xml('iq', { type: 'set',
to,
id: `dial-${++_counter}` },
xml('dial', { xmlns: 'urn:xmpp:rayo:1' }, ...children)
)
);
},

/**
* Sends a disco#info IQ and resolves with the response stanza.
* @param {string} targetJid
Expand Down
106 changes: 106 additions & 0 deletions tests/prosody/mod_trace_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,111 @@
assert.strictEqual(span.parent_span_id, parentId,
'exported span must record the original parent_id as its own parent');
});

it('leaves a dial IQ without a traceparent or X-Traceparent header unmodified and exports nothing', async () => {

Check failure on line 123 in tests/prosody/mod_trace_spec.js

View workflow job for this annotation

GitHub Actions / Lint

This line has a length of 121. Maximum allowed is 120
const [ a, b ] = await connectPair('trace.localhost', clients);

await a.sendDialIq(b.jid);

const iq = await b.waitForIq(s => s.getChild('dial', 'urn:xmpp:rayo:1'));

assert.ok(!iq.getChild('dial', 'urn:xmpp:rayo:1').getChild('traceparent'));

await new Promise(r => setTimeout(r, 300));

const traces = await getOtlpTraces();

assert.strictEqual(traces.length, 0);
});

it('rewrites the traceparent parent_id and exports a rayo.dial span for a dial IQ with a traceparent element', async () => {

Check failure on line 139 in tests/prosody/mod_trace_spec.js

View workflow job for this annotation

GitHub Actions / Lint

This line has a length of 132. Maximum allowed is 120
const [ a, b ] = await connectPair('trace.localhost', clients);
const traceId = 'c'.repeat(32);
const parentId = 'f'.repeat(16);

await a.sendDialIq(b.jid, { traceId,
parentId });

const iq = await b.waitForIq(s => s.getChild('dial', 'urn:xmpp:rayo:1'));
const dial = iq.getChild('dial', 'urn:xmpp:rayo:1');
const tp = dial.getChild('traceparent');

assert.ok(tp, 'traceparent element should still be present');
assert.strictEqual(tp.attrs.trace_id, traceId, 'trace_id must be preserved');
assert.notStrictEqual(tp.attrs.parent_id, parentId, 'parent_id must be rewritten to the new span id');
assert.match(tp.attrs.parent_id, /^[0-9a-f]{16}$/, 'rewritten parent_id must be a 16-hex-char span id');

await new Promise(r => setTimeout(r, 300));

const traces = await getOtlpTraces();

assert.strictEqual(traces.length, 1, 'exactly one export request should have been made');

const [ resourceSpan ] = traces[0].resource_spans;
const [ scopeSpan ] = resourceSpan.scope_spans;
const [ span ] = scopeSpan.spans;

assert.strictEqual(span.name, 'rayo.dial');
assert.strictEqual(span.trace_id, traceId);
assert.strictEqual(span.parent_span_id, parentId,
'exported span must record the original parent_id as its own parent');
});

it('rewrites the X-Traceparent header value and exports a rayo.dial span for a dial IQ with no traceparent element', async () => {

Check failure on line 172 in tests/prosody/mod_trace_spec.js

View workflow job for this annotation

GitHub Actions / Lint

This line has a length of 138. Maximum allowed is 120
const [ a, b ] = await connectPair('trace.localhost', clients);
const traceId = '1'.repeat(32);
const parentId = '2'.repeat(16);
const flags = '01';

await a.sendDialIq(b.jid, { xTraceparentValue: `00-${traceId}-${parentId}-${flags}` });

const iq = await b.waitForIq(s => s.getChild('dial', 'urn:xmpp:rayo:1'));
const dial = iq.getChild('dial', 'urn:xmpp:rayo:1');
const header = dial.getChildren('header').find(h => h.attrs.name === 'X-Traceparent');

assert.ok(header, 'X-Traceparent header should still be present');

const [ , newTraceId, newParentId, newFlags ] =

Check failure on line 186 in tests/prosody/mod_trace_spec.js

View workflow job for this annotation

GitHub Actions / Lint

'=' should be placed at the beginning of the line
/^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(header.attrs.value) ?? [];

assert.strictEqual(newTraceId, traceId, 'trace_id must be preserved');
assert.notStrictEqual(newParentId, parentId, 'parent_id must be rewritten to the new span id');
assert.strictEqual(newFlags, flags, 'flags must be preserved');

await new Promise(r => setTimeout(r, 300));

const traces = await getOtlpTraces();

assert.strictEqual(traces.length, 1, 'exactly one export request should have been made');

const [ resourceSpan ] = traces[0].resource_spans;
const [ scopeSpan ] = resourceSpan.scope_spans;
const [ span ] = scopeSpan.spans;

assert.strictEqual(span.name, 'rayo.dial');
assert.strictEqual(span.trace_id, traceId);
assert.strictEqual(span.parent_span_id, parentId,
'exported span must record the original parent_id as its own parent');
});

it('leaves a malformed X-Traceparent header unmodified and exports nothing', async () => {
const [ a, b ] = await connectPair('trace.localhost', clients);
const malformedValue = '00-not-a-valid-traceparent';

await a.sendDialIq(b.jid, { xTraceparentValue: malformedValue });

const iq = await b.waitForIq(s => s.getChild('dial', 'urn:xmpp:rayo:1'));
const dial = iq.getChild('dial', 'urn:xmpp:rayo:1');
const header = dial.getChildren('header').find(h => h.attrs.name === 'X-Traceparent');

assert.ok(header, 'X-Traceparent header should still be present');
assert.strictEqual(header.attrs.value, malformedValue, 'malformed header value must be left untouched');

await new Promise(r => setTimeout(r, 300));

const traces = await getOtlpTraces();

assert.strictEqual(traces.length, 0, 'no span should be exported for a malformed X-Traceparent value');
});
});
});
Loading