* 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>
228 lines
9.6 KiB
JavaScript
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'
|
|
);
|
|
});
|
|
});
|