File
Blob: src/cloudflare/internal/test/tracing/tracing-log-attribution-instrumentation-test.js
| 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 | |
| 10 | import 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 |
| 18 | const state = { |
| 19 | invocationPromises: [], |
| 20 | byInvocation: new Map(), |
| 21 | }; |
| 22 | |
| 23 | function 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 | |
| 39 | export 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. |
| 113 | function 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. |
| 136 | function logContains(log, needle) { |
| 137 | try { |
| 138 | return JSON.stringify(log.message).includes(needle); |
| 139 | } catch (_) { |
| 140 | return false; |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | function 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 | |
| 159 | function 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 | |
| 169 | export 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 | }; |