Skip to content
File

Blob: src/cloudflare/internal/test/tracing/tracing-log-attribution-instrumentation-test.js

javascript291 lines
1// Copyright (c) 2026 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5// Tail-worker side of tracing-log-attribution-test. Collects streaming tail events
6// and asserts that Log/Exception/DiagnosticChannelEvent events carry the spanId of
7// the currently-active user span (as opened by `withSpan`/`enterSpan`), rather
8// than the root invocation's spanId.
9 
10import assert from 'node:assert';
11 
12// Per-invocation collector:
13// spans: Map<spanId, { spanId, parentSpanId, name, invocationId, closed, ...attrs }>
14// logs: Array<{ message, spanContextSpanId, invocationId }>
15// exceptions: Array<{ name, message, spanContextSpanId, invocationId }>
16// diagnosticChannelEvents: Array<{ channel, spanContextSpanId, invocationId }>
17// topLevelSpanId: string // from the onset event
18const state = {
19 invocationPromises: [],
20 byInvocation: new Map(),
21};
22 
23function getOrCreateInvocation(invocationId) {
24 let inv = state.byInvocation.get(invocationId);
25 if (!inv) {
26 inv = {
27 invocationId,
28 spans: new Map(),
29 logs: [],
30 exceptions: [],
31 diagnosticChannelEvents: [],
32 topLevelSpanId: null,
33 };
34 state.byInvocation.set(invocationId, inv);
35 }
36 return inv;
37}
38 
39export default {
40 tailStream(event, env, ctx) {
41 // `event` is the onset event. `event.event.spanId` is the top-level span ID
42 // for this invocation; every other event's `spanContext.spanId` describes the
43 // span that was active at event time.
44 const inv = getOrCreateInvocation(event.invocationId);
45 inv.topLevelSpanId = event.event.spanId;
46 
47 let resolveFn;
48 state.invocationPromises.push(
49 new Promise((resolve) => {
50 resolveFn = resolve;
51 })
52 );
53 
54 return (event) => {
55 const inv = getOrCreateInvocation(event.invocationId);
56 const currentSpanId = event.spanContext?.spanId;
57 switch (event.event.type) {
58 case 'spanOpen':
59 inv.spans.set(event.event.spanId, {
60 spanId: event.event.spanId,
61 parentSpanId: currentSpanId,
62 name: event.event.name,
63 invocationId: event.invocationId,
64 });
65 break;
66 case 'attributes': {
67 // Attributes belong to the span whose id equals spanContext.spanId.
68 const span = inv.spans.get(currentSpanId);
69 if (span) {
70 for (const { name, value } of event.event.info) {
71 span[name] = value;
72 }
73 }
74 break;
75 }
76 case 'spanClose': {
77 const span = inv.spans.get(currentSpanId);
78 if (span) span.closed = true;
79 break;
80 }
81 case 'log':
82 inv.logs.push({
83 message: event.event.message,
84 spanContextSpanId: currentSpanId,
85 invocationId: event.invocationId,
86 });
87 break;
88 case 'exception':
89 inv.exceptions.push({
90 name: event.event.name,
91 message: event.event.message,
92 spanContextSpanId: currentSpanId,
93 invocationId: event.invocationId,
94 });
95 break;
96 case 'diagnosticChannel':
97 inv.diagnosticChannelEvents.push({
98 channel: event.event.channel,
99 spanContextSpanId: currentSpanId,
100 invocationId: event.invocationId,
101 });
102 break;
103 case 'outcome':
104 resolveFn();
105 break;
106 }
107 };
108 },
109};
110 
111// Locate the invocation whose span set contains a span with the given name.
112// Also returns that span for convenience.
113function findInvocationBySpanName(spanName) {
114 const matches = [];
115 for (const inv of state.byInvocation.values()) {
116 for (const span of inv.spans.values()) {
117 if (span.name === spanName) {
118 matches.push({ inv, span });
119 }
120 }
121 }
122 assert.strictEqual(
123 matches.length,
124 1,
125 `Expected exactly one invocation containing span "${spanName}", got ${matches.length}`
126 );
127 return matches[0];
128}
129 
130// Locate the invocation that produced exactly the set of top-level (non-nested)
131// spans in `spanNames`. Used for the logOutsideAnySpan case, which opens no
132// user spans but is identified by the single log it emits.
133// Log events have their `message` set to the JSON-parsed console.log argument
134// array (see Log::ToJs in trace-stream.c++), so we need a deep stringify to
135// search within.
136function logContains(log, needle) {
137 try {
138 return JSON.stringify(log.message).includes(needle);
139 } catch (_) {
140 return false;
141 }
142}
143 
144function findInvocationByLogMessage(message) {
145 const matches = [];
146 for (const inv of state.byInvocation.values()) {
147 if (inv.logs.some((l) => logContains(l, message))) {
148 matches.push(inv);
149 }
150 }
151 assert.strictEqual(
152 matches.length,
153 1,
154 `Expected exactly one invocation with log message containing "${message}", got ${matches.length}`
155 );
156 return matches[0];
157}
158 
159function findLog(inv, substring) {
160 const matches = inv.logs.filter((l) => logContains(l, substring));
161 assert.strictEqual(
162 matches.length,
163 1,
164 `Expected exactly one log containing "${substring}" in invocation ${inv.invocationId}, got ${matches.length}`
165 );
166 return matches[0];
167}
168 
169export const validate = {
170 async test() {
171 // Wait for every invocation's `outcome` event to arrive.
172 await Promise.allSettled(state.invocationPromises);
173 
174 // ---------- Case 1: logInsideEnterSpan ----------
175 // Log inside a single enterSpan must be attributed to that span (not the root).
176 {
177 const { inv, span } = findInvocationBySpanName('log-attr-single');
178 const log = findLog(inv, 'log-attr-single-message');
179 assert.strictEqual(
180 log.spanContextSpanId,
181 span.spanId,
182 `logInsideEnterSpan: log.spanContext.spanId (${log.spanContextSpanId}) should equal the enclosing span id (${span.spanId})`
183 );
184 assert.notStrictEqual(
185 log.spanContextSpanId,
186 inv.topLevelSpanId,
187 'logInsideEnterSpan: log should NOT be attributed to the root invocation span'
188 );
189 }
190 
191 // ---------- Case 2: logInsideNestedEnterSpan ----------
192 {
193 const { inv, span: outer } = findInvocationBySpanName('log-attr-outer');
194 const inner = Array.from(inv.spans.values()).find(
195 (s) => s.name === 'log-attr-inner'
196 );
197 assert.ok(inner, 'expected an inner span');
198 assert.strictEqual(
199 inner.parentSpanId,
200 outer.spanId,
201 'inner span must be a child of outer'
202 );
203 
204 const beforeInner = findLog(inv, 'log-attr-outer-before-inner');
205 const innerMsg = findLog(inv, 'log-attr-inner-message');
206 const afterInner = findLog(inv, 'log-attr-outer-after-inner');
207 
208 assert.strictEqual(
209 beforeInner.spanContextSpanId,
210 outer.spanId,
211 'log before inner must be attributed to outer'
212 );
213 assert.strictEqual(
214 innerMsg.spanContextSpanId,
215 inner.spanId,
216 'log inside inner must be attributed to inner (not outer, not root)'
217 );
218 assert.notStrictEqual(
219 innerMsg.spanContextSpanId,
220 inv.topLevelSpanId,
221 'log inside inner must NOT be attributed to the root'
222 );
223 assert.strictEqual(
224 afterInner.spanContextSpanId,
225 outer.spanId,
226 'log after inner closes must be re-attributed to outer'
227 );
228 }
229 
230 // ---------- Case 3: logAcrossAwait ----------
231 // This is the acid test: the fix only works if the AsyncContextFrame carrying
232 // the user span propagates across the microtask boundary introduced by `await`.
233 {
234 const { inv, span } = findInvocationBySpanName('log-attr-async');
235 const log = findLog(inv, 'log-attr-async-message');
236 assert.strictEqual(
237 log.spanContextSpanId,
238 span.spanId,
239 'logAcrossAwait: log inside async enterSpan after await must be attributed to that span'
240 );
241 assert.notStrictEqual(
242 log.spanContextSpanId,
243 inv.topLevelSpanId,
244 'logAcrossAwait: log must NOT be attributed to the root'
245 );
246 }
247 
248 // ---------- Case 4: logOutsideAnySpan (fall-back path) ----------
249 // No user span is active, so the log's spanContext.spanId must equal the
250 // invocation's top-level (onset) span id. This proves we don't break logs
251 // that have no user span to attribute to.
252 {
253 const inv = findInvocationByLogMessage('log-attr-root-message');
254 const log = findLog(inv, 'log-attr-root-message');
255 assert.strictEqual(
256 log.spanContextSpanId,
257 inv.topLevelSpanId,
258 'logOutsideAnySpan: with no user span active, log must fall back to the root invocation span'
259 );
260 }
261 
262 // ---------- Case 5: diagnosticsChannelInsideEnterSpan ----------
263 {
264 const { inv, span } = findInvocationBySpanName('log-attr-dc');
265 assert.strictEqual(
266 inv.diagnosticChannelEvents.length,
267 1,
268 'expected exactly one diagnosticChannelEvent'
269 );
270 const dce = inv.diagnosticChannelEvents[0];
271 assert.strictEqual(
272 dce.channel,
273 'log-attr-dc-channel',
274 'diagnosticChannelEvent channel name'
275 );
276 assert.strictEqual(
277 dce.spanContextSpanId,
278 span.spanId,
279 'diagnosticChannelEvent must be attributed to the enclosing user span'
280 );
281 assert.notStrictEqual(
282 dce.spanContextSpanId,
283 inv.topLevelSpanId,
284 'diagnosticChannelEvent must NOT be attributed to the root'
285 );
286 }
287 
288 console.log('All tracing-log-attribution tests passed!');
289 },
290};