Files
msd-core/tests/dispatch/trace-correlation.test.cjs
Tom Boucher 5f3eb42864 feat(observability): propagate parentTraceId on DispatchEvent — ADR-0174 SDK retirement Phase 1.4 (#178) (#225)
* test(#178): update DispatchEvent factory tests to propagate parentTraceId

P1.3 test 'parentTraceId is always undefined' replaced with four P1.4
contracts: absent → undefined, string → propagated, null → undefined,
non-string → undefined (defensive normalization policy).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#178): propagate parentTraceId through DispatchEvent factory

Stop ignoring the parentTraceId parameter added as a forward-compat hook
in P1.3. Defensive normalization: only non-null strings are propagated;
null, non-string values, and absent callers all yield undefined, keeping
P1.3 behavior intact for all existing dispatch call sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#178): add Hub-level parentTraceId propagation tests

Four new assertions: req.parentTraceId propagates to event, absent →
undefined (P1.3 regression), shared parentTraceId across multiple
dispatches, and unique traceId invariant despite shared parentTraceId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#178): plumb parentTraceId through Hub dispatch and _notifyLogger

dispatch() now reads req.parentTraceId and passes it to _notifyLogger,
which forwards it to makeDispatchEvent. Backward-compatible: callers
that omit parentTraceId emit events with parentTraceId: undefined,
identical to P1.3 behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#178): add trace correlation end-to-end test

Dispatches a root command then 3 children with parentTraceId=rootTraceId.
Reads the real .gsd-trace.jsonl audit file and verifies: 4 events total,
root has no parentTraceId, all children carry rootTraceId, all traceIds
unique, JS filter returns exactly the 3 children given the root's traceId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(#178): document traceId/parentTraceId in audit file

Update Observability section to note that audit events now carry both
traceId and parentTraceId, and explain the correlation filter pattern.
Note that leaf dispatches emit parentTraceId: undefined until the Phase 2
composer wires it automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(#178): add changeset for trace correlation seam

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#178): cover invalid parentTraceId values in DispatchEvent factory

Adds 9 new test cases for UUID v4 validation of parentTraceId:
empty string, whitespace, non-UUID, oversized, UUID v1, missing-hyphen,
extra-char (all dropped to undefined), plus UPPERCASE and lowercase v4
(both propagated). Tests are intentionally red until the implementation
commit that follows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#178): validate parentTraceId against UUID v4 before propagation

Adds UUID_V4_REGEX constant and isValidParentTraceId() helper to
event.cjs. makeDispatchEvent now silently coerces any parentTraceId that
fails the UUID v4 check (wrong version nibble, wrong variant, missing
hyphens, oversized, empty, etc.) to undefined. No stderr warn is emitted
— the factory remains pure and side-effect-free. Closes the correlation-
poisoning vector identified in the Codex adversarial review of PR #225.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#178): assert Hub silently drops invalid parentTraceId at the seam

Adds two tests to hub-logger-integration.test.cjs:
1. dispatch with 'junk' parentTraceId emits event with parentTraceId===undefined.
2. The logger-failure warn path is NOT triggered — the factory coerces the bad
   value before onEvent is called, confirmed by zero stderr output even when a
   logger that would throw on non-undefined parentTraceId is installed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#178): assert invalid parentTraceId does not poison correlation siblings

Adds one test to trace-correlation.test.cjs: dispatches a root, a valid
child (parentTraceId = rootTraceId), and an invalid child (parentTraceId =
'junk'). Asserts: valid child carries correct parentTraceId, invalid child
has parentTraceId dropped to undefined, filtering by rootTraceId yields
exactly 1 event (the valid child only), and all 3 events have unique
traceIds. Uses an isolated Hub + tmpdir to avoid shared fixture interference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(#178): document UUID v4 contract for parentTraceId

Appends one sentence to the Observability audit-trail paragraph in
CONFIGURATION.md: parentTraceId must be canonical UUID v4 (RFC 4122);
values that don't match are silently dropped from audit output. No section
restructuring — single sentence addition only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:32:28 -04:00

228 lines
9.6 KiB
JavaScript

'use strict';
/**
* End-to-end trace correlation tests (issue #178).
*
* Demonstrates the full parentTraceId propagation seam:
* 1. A Hub is created with a real DispatchLogger writing to a real temp audit file.
* 2. A "root" dispatch produces a root event (parentTraceId === undefined).
* 3. Three "child" dispatches pass parentTraceId = root.traceId.
* 4. The audit file is read back and all four events are verified.
* 5. A JS filter (simulating jq) confirms the three children are recoverable
* by filtering on parentTraceId === rootTraceId.
*
* No mocks. No fs stubs. Real file I/O to os.tmpdir().
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createHub } = require('../../get-shit-done/bin/lib/command-routing-hub.cjs');
const { createDefaultLogger } = require('../../get-shit-done/bin/lib/observability/logger.cjs');
// ─── Test fixture setup ───────────────────────────────────────────────────────
/** Minimal registry that always succeeds — all we care about is the event shape. */
function makeRegistry() {
return {
plan: { '': () => ({ ok: true, data: 'plan-ok' }) },
discuss: { '': () => ({ ok: true, data: 'discuss-ok' }) },
test: { '': () => ({ ok: true, data: 'test-ok' }) },
};
}
function makeManifest() {
return {
plan: [''],
discuss: [''],
test: [''],
};
}
/** Parse a JSONL file into an array of parsed event objects. */
function readJsonl(filePath) {
const raw = fs.readFileSync(filePath, 'utf8').trim();
if (!raw) return [];
return raw.split('\n').map(line => JSON.parse(line));
}
// ─── Shared state for the test group ─────────────────────────────────────────
let tmpDir;
let auditPath;
let savedAudit;
let capturedEvents;
describe('trace correlation — end-to-end parentTraceId propagation', () => {
before(() => {
// Create an isolated temp dir for this test group
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-trace-correlation-test-'));
auditPath = path.join(tmpDir, '.planning', '.gsd-trace.jsonl');
// Enable audit file writes
savedAudit = process.env.GSD_AUDIT;
process.env.GSD_AUDIT = '1';
// Build a Hub backed by the real logger writing to our temp dir
const logger = createDefaultLogger({ cwd: tmpDir });
const hub = createHub({
cjsRegistry: makeRegistry(),
manifest: makeManifest(),
logger,
});
// ── Root dispatch (no parentTraceId) ──────────────────────────────────────
hub.dispatch({ family: 'plan', subcommand: '' });
// ── Read root traceId from the audit file ─────────────────────────────────
const rootEvents = readJsonl(auditPath);
assert.equal(rootEvents.length, 1, 'setup: one event after root dispatch');
const rootTraceId = rootEvents[0].traceId;
// ── Child dispatches — each passes parentTraceId = rootTraceId ────────────
hub.dispatch({ family: 'plan', subcommand: '', parentTraceId: rootTraceId });
hub.dispatch({ family: 'discuss', subcommand: '', parentTraceId: rootTraceId });
hub.dispatch({ family: 'test', subcommand: '', parentTraceId: rootTraceId });
// Capture all events for the assertions below
capturedEvents = readJsonl(auditPath);
});
after(() => {
// Restore GSD_AUDIT to whatever it was before this test group
if (savedAudit === undefined) {
delete process.env.GSD_AUDIT;
} else {
process.env.GSD_AUDIT = savedAudit;
}
// Clean up the temp directory
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ── Assertions ────────────────────────────────────────────────────────────
test('audit file contains exactly 4 events (1 root + 3 children)', () => {
assert.equal(capturedEvents.length, 4,
'audit file must contain exactly 4 events');
});
test('root event has parentTraceId === undefined', () => {
const root = capturedEvents[0];
// When parentTraceId is undefined it is omitted from JSON serialization.
// Either absent or explicitly undefined → the contract is "no parent".
const hasNoParent = !('parentTraceId' in root) || root.parentTraceId === undefined || root.parentTraceId === null;
assert.ok(hasNoParent,
`root event must have no parentTraceId, got: ${root.parentTraceId}`);
});
test('all 3 child events carry parentTraceId === rootTraceId', () => {
const rootTraceId = capturedEvents[0].traceId;
const children = capturedEvents.slice(1);
assert.equal(children.length, 3, 'there must be exactly 3 child events');
for (const child of children) {
assert.strictEqual(child.parentTraceId, rootTraceId,
`child event must carry parentTraceId=${rootTraceId}, got: ${child.parentTraceId}`);
}
});
test('all 4 events have unique traceIds', () => {
const ids = capturedEvents.map(e => e.traceId);
const unique = new Set(ids);
assert.equal(unique.size, 4,
'all 4 events must have unique traceIds even when parentTraceId is shared');
});
test('JS filter on parentTraceId returns exactly the 3 children (jq-style)', () => {
const rootTraceId = capturedEvents[0].traceId;
// Simulates: jq 'select(.parentTraceId == $rootTraceId)' .gsd-trace.jsonl
const children = capturedEvents.filter(e => e.parentTraceId === rootTraceId);
assert.equal(children.length, 3,
'filtering events by parentTraceId === rootTraceId must yield exactly 3 events');
// Confirm the root itself is not in the filtered set
const rootInChildren = children.some(e => e.traceId === rootTraceId);
assert.ok(!rootInChildren,
'the root event must not appear in the children filter result');
});
test('invalid parentTraceId in a child dispatch breaks the correlation tree for that child but does not poison sibling traces', () => {
// This test uses its own isolated Hub + audit file to avoid interfering with
// the shared capturedEvents fixture above.
const isolatedTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-poison-test-'));
let isolatedSavedAudit;
try {
isolatedSavedAudit = process.env.GSD_AUDIT;
process.env.GSD_AUDIT = '1';
const logger = createDefaultLogger({ cwd: isolatedTmp });
const hub = createHub({
cjsRegistry: makeRegistry(),
manifest: makeManifest(),
logger,
});
// Root dispatch — no parentTraceId
hub.dispatch({ family: 'plan', subcommand: '' });
const isolatedAuditPath = path.join(isolatedTmp, '.planning', '.gsd-trace.jsonl');
const afterRoot = readJsonl(isolatedAuditPath);
assert.equal(afterRoot.length, 1, 'setup: one root event');
const rootTraceId = afterRoot[0].traceId;
// Valid child: passes rootTraceId as parentTraceId
hub.dispatch({ family: 'discuss', subcommand: '', parentTraceId: rootTraceId });
// Invalid child: passes 'junk' as parentTraceId
hub.dispatch({ family: 'test', subcommand: '', parentTraceId: 'junk' });
const allEvents = readJsonl(isolatedAuditPath);
assert.equal(allEvents.length, 3, 'must have 3 events total (root + valid child + invalid child)');
const [root, validChild, invalidChild] = allEvents;
// Valid child carries the correct parentTraceId
assert.strictEqual(validChild.parentTraceId, rootTraceId,
'valid child must carry parentTraceId === rootTraceId');
// Invalid child has parentTraceId coerced to undefined (absent from JSON)
const invalidChildHasNoParent =
!('parentTraceId' in invalidChild) ||
invalidChild.parentTraceId === undefined ||
invalidChild.parentTraceId === null;
assert.ok(invalidChildHasNoParent,
'invalid child must have parentTraceId dropped to undefined — not "junk"');
// Sibling relations are unaffected: filtering by rootTraceId yields only the valid child
const correlatedChildren = allEvents.filter(e => e.parentTraceId === rootTraceId);
assert.equal(correlatedChildren.length, 1,
'only the valid child must appear when filtering by rootTraceId — invalid child must not contaminate');
assert.strictEqual(correlatedChildren[0].traceId, validChild.traceId,
'the correlated child must be the valid one');
// All three events still have unique traceIds
const ids = allEvents.map(e => e.traceId);
assert.equal(new Set(ids).size, 3, 'all 3 events must have unique traceIds');
} finally {
if (isolatedSavedAudit === undefined) {
delete process.env.GSD_AUDIT;
} else {
process.env.GSD_AUDIT = isolatedSavedAudit;
}
fs.rmSync(isolatedTmp, { recursive: true, force: true });
}
});
test('JS filter on traceId returns only the root event', () => {
const rootTraceId = capturedEvents[0].traceId;
// Simulates: jq 'select(.traceId == $rootTraceId)'
const roots = capturedEvents.filter(e => e.traceId === rootTraceId);
assert.equal(roots.length, 1, 'filtering by traceId must return exactly one root event');
assert.ok(
!('parentTraceId' in roots[0]) || roots[0].parentTraceId === undefined || roots[0].parentTraceId === null,
'the root event found by traceId must have no parentTraceId'
);
});
});