From 2da84a86c994401aaffbf7971b1ce1938aee014d Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Thu, 9 Jul 2026 03:10:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(core):=20trace=20diffing=20=E2=80=94=20dif?= =?UTF-8?q?fTraces()=20API=20for=20comparing=20two=20parsed=20traces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #85 --- .changeset/v03-trace-diffing.md | 9 + CURRENT_STATE.md | 1 + packages/toolkit/src/core/diff.test.ts | 511 +++++++++++++++++++++++++ packages/toolkit/src/core/diff.ts | 251 ++++++++++++ packages/toolkit/src/core/index.ts | 4 + packages/toolkit/src/index.ts | 4 + 6 files changed, 780 insertions(+) create mode 100644 .changeset/v03-trace-diffing.md create mode 100644 packages/toolkit/src/core/diff.test.ts create mode 100644 packages/toolkit/src/core/diff.ts diff --git a/.changeset/v03-trace-diffing.md b/.changeset/v03-trace-diffing.md new file mode 100644 index 0000000..bef5fa1 --- /dev/null +++ b/.changeset/v03-trace-diffing.md @@ -0,0 +1,9 @@ +--- +'@ocpp-debugkit/toolkit': minor +--- + +Add `diffTraces()` — compare two parsed traces and surface event-level differences: +- Events only in A or B (by messageId) +- Field-level modifications (timestamp, direction, action, payload, errorCode) +- Failure differences (detected in A but not B, and vice versa) +- Summary differences (event count, failure count, duration, status) diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 30c7140..6a7f841 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -200,6 +200,7 @@ integration examples, and contributor onboarding. - ✅ v0.3 failure detection rules — 5 new rules (Issue #84): `SUSPICIOUS_SESSION_DURATION`, `SLOW_RESPONSE`, `HEARTBEAT_INTERVAL_VIOLATION`, `METER_VALUE_ANOMALY`, `UNRESPONSIVE_CSMS` (15 total rules) +- ✅ Trace diffing — `diffTraces()` API (Issue #85) ## What's Next diff --git a/packages/toolkit/src/core/diff.test.ts b/packages/toolkit/src/core/diff.test.ts new file mode 100644 index 0000000..0f7cde5 --- /dev/null +++ b/packages/toolkit/src/core/diff.test.ts @@ -0,0 +1,511 @@ +import { describe, it, expect } from 'vitest'; +import { diffTraces } from './diff.js'; +import { parseTrace } from './parser.js'; +import type { Event, RawOcppMessage } from './types.js'; + +// Helpers +function makeEvent( + id: string, + messageId: string, + messageType: 'Call' | 'CallResult' | 'CallError', + action: string | null, + payload: unknown = {}, + timestamp: number | null = null, + direction: 'CS_TO_CSMS' | 'CSMS_TO_CS' | 'UNKNOWN' = 'CS_TO_CSMS', +): Event { + let rawMessage: RawOcppMessage; + if (messageType === 'Call') { + rawMessage = [2, messageId, action as string, payload]; + } else if (messageType === 'CallResult') { + rawMessage = [3, messageId, payload]; + } else { + rawMessage = [4, messageId, 'Error', 'desc', payload]; + } + return { + id, + messageId, + timestamp, + direction, + messageType, + action, + payload, + errorCode: messageType === 'CallError' ? 'Error' : null, + errorDescription: messageType === 'CallError' ? 'desc' : null, + rawMessage, + }; +} + +function makeParseResult(events: Event[]) { + return { events, warnings: [] }; +} + +describe('diffTraces', () => { + it('returns empty diff for identical traces', () => { + const events = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'e2', + 'm1', + 'CallResult', + null, + { status: 'Accepted', interval: 60 }, + 1500, + 'CSMS_TO_CS', + ), + ]; + const a = makeParseResult(events); + const b = makeParseResult([...events]); + const diff = diffTraces(a, b); + + expect(diff.onlyInA).toHaveLength(0); + expect(diff.onlyInB).toHaveLength(0); + expect(diff.modified).toHaveLength(0); + expect(diff.failuresOnlyInA).toHaveLength(0); + expect(diff.failuresOnlyInB).toHaveLength(0); + }); + + it('detects events only in A', () => { + const eventsA = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'e2', + 'm1', + 'CallResult', + null, + { status: 'Accepted', interval: 60 }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent('e3', 'm2', 'Call', 'Heartbeat', {}, 2000), + ]; + const eventsB = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'e2', + 'm1', + 'CallResult', + null, + { status: 'Accepted', interval: 60 }, + 1500, + 'CSMS_TO_CS', + ), + ]; + const diff = diffTraces(makeParseResult(eventsA), makeParseResult(eventsB)); + + expect(diff.onlyInA).toHaveLength(1); + expect(diff.onlyInA[0]?.messageId).toBe('m2'); + expect(diff.onlyInB).toHaveLength(0); + }); + + it('detects events only in B', () => { + const eventsA = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + ]; + const eventsB = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent('e2', 'm2', 'Call', 'Heartbeat', {}, 2000), + ]; + const diff = diffTraces(makeParseResult(eventsA), makeParseResult(eventsB)); + + expect(diff.onlyInB).toHaveLength(1); + expect(diff.onlyInB[0]?.messageId).toBe('m2'); + expect(diff.onlyInA).toHaveLength(0); + }); + + it('detects timestamp differences', () => { + const eventsA = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + ]; + const eventsB = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 2000, + ), + ]; + const diff = diffTraces(makeParseResult(eventsA), makeParseResult(eventsB)); + + expect(diff.modified).toHaveLength(1); + expect(diff.modified[0]?.field).toBe('timestamp'); + expect(diff.modified[0]?.valueA).toBe(1000); + expect(diff.modified[0]?.valueB).toBe(2000); + }); + + it('detects direction differences', () => { + const eventsA = [makeEvent('e1', 'm1', 'Call', 'BootNotification', {}, 1000, 'CS_TO_CSMS')]; + const eventsB = [makeEvent('e1', 'm1', 'Call', 'BootNotification', {}, 1000, 'CSMS_TO_CS')]; + const diff = diffTraces(makeParseResult(eventsA), makeParseResult(eventsB)); + + const directionMods = diff.modified.filter((m) => m.field === 'direction'); + expect(directionMods).toHaveLength(1); + expect(directionMods[0]?.valueA).toBe('CS_TO_CSMS'); + expect(directionMods[0]?.valueB).toBe('CSMS_TO_CS'); + }); + + it('detects action differences', () => { + const eventsA = [makeEvent('e1', 'm1', 'Call', 'BootNotification', {}, 1000)]; + const eventsB = [makeEvent('e1', 'm1', 'Call', 'Heartbeat', {}, 1000)]; + const diff = diffTraces(makeParseResult(eventsA), makeParseResult(eventsB)); + + const actionMods = diff.modified.filter((m) => m.field === 'action'); + expect(actionMods).toHaveLength(1); + expect(actionMods[0]?.valueA).toBe('BootNotification'); + expect(actionMods[0]?.valueB).toBe('Heartbeat'); + }); + + it('detects payload differences', () => { + const eventsA = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + ]; + const eventsB = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-002' }, + 1000, + ), + ]; + const diff = diffTraces(makeParseResult(eventsA), makeParseResult(eventsB)); + + const payloadMods = diff.modified.filter((m) => m.field === 'payload'); + expect(payloadMods).toHaveLength(1); + }); + + it('does not flag identical payloads', () => { + const eventsA = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001', firmwareVersion: '1.0.0' }, + 1000, + ), + ]; + const eventsB = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001', firmwareVersion: '1.0.0' }, + 1000, + ), + ]; + const diff = diffTraces(makeParseResult(eventsA), makeParseResult(eventsB)); + + expect(diff.modified).toHaveLength(0); + }); + + it('detects errorCode differences', () => { + const eventsA = [makeEvent('e1', 'm1', 'CallError', null, {}, 1000, 'CSMS_TO_CS')]; + const eventsB: Event[] = [ + { + ...makeEvent('e1', 'm1', 'CallError', null, {}, 1000, 'CSMS_TO_CS'), + errorCode: 'DifferentError', + }, + ]; + const diff = diffTraces(makeParseResult(eventsA), makeParseResult(eventsB)); + + const errorMods = diff.modified.filter((m) => m.field === 'errorCode'); + expect(errorMods).toHaveLength(1); + expect(errorMods[0]?.valueA).toBe('Error'); + expect(errorMods[0]?.valueB).toBe('DifferentError'); + }); + + it('detects failure differences', () => { + // Trace A: failed authorization + const eventsA = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'e2', + 'm1', + 'CallResult', + null, + { status: 'Accepted', interval: 60 }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent('e3', 'm2', 'Call', 'Authorize', { idTag: 'BAD-TAG' }, 2000), + makeEvent( + 'e4', + 'm2', + 'CallResult', + null, + { idTagInfo: { status: 'Invalid' } }, + 2500, + 'CSMS_TO_CS', + ), + ]; + + // Trace B: accepted authorization + const eventsB = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'e2', + 'm1', + 'CallResult', + null, + { status: 'Accepted', interval: 60 }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent('e3', 'm2', 'Call', 'Authorize', { idTag: 'GOOD-TAG' }, 2000), + makeEvent( + 'e4', + 'm2', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 2500, + 'CSMS_TO_CS', + ), + ]; + + const diff = diffTraces(makeParseResult(eventsA), makeParseResult(eventsB)); + + expect(diff.failuresOnlyInA.some((f) => f.code === 'FAILED_AUTHORIZATION')).toBe(true); + expect(diff.failuresOnlyInB.some((f) => f.code === 'FAILED_AUTHORIZATION')).toBe(false); + }); + + it('detects summary differences', () => { + const eventsA = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'e2', + 'm1', + 'CallResult', + null, + { status: 'Accepted', interval: 60 }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent('e3', 'm2', 'Call', 'Authorize', { idTag: 'TAG-001' }, 2000), + makeEvent( + 'e4', + 'm2', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 2500, + 'CSMS_TO_CS', + ), + makeEvent('e5', 'm3', 'Call', 'StartTransaction', { connectorId: 1, idTag: 'TAG-001' }, 3000), + makeEvent( + 'e6', + 'm3', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' }, transactionId: 42 }, + 3500, + 'CSMS_TO_CS', + ), + makeEvent( + 'e7', + 'm4', + 'Call', + 'StopTransaction', + { transactionId: 42, reason: 'Local' }, + 3000 + 30 * 60 * 1000, + ), + makeEvent( + 'e8', + 'm4', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 3000 + 30 * 60 * 1000 + 500, + 'CSMS_TO_CS', + ), + ]; + + const eventsB = [ + makeEvent( + 'e1', + 'm1', + 'Call', + 'BootNotification', + { chargePointSerialNumber: 'CS-001' }, + 1000, + ), + makeEvent( + 'e2', + 'm1', + 'CallResult', + null, + { status: 'Accepted', interval: 60 }, + 1500, + 'CSMS_TO_CS', + ), + makeEvent('e3', 'm2', 'Call', 'Authorize', { idTag: 'TAG-001' }, 2000), + makeEvent( + 'e4', + 'm2', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 2500, + 'CSMS_TO_CS', + ), + makeEvent('e5', 'm3', 'Call', 'StartTransaction', { connectorId: 1, idTag: 'TAG-001' }, 3000), + makeEvent( + 'e6', + 'm3', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' }, transactionId: 99 }, + 3500, + 'CSMS_TO_CS', + ), + makeEvent( + 'e7', + 'm4', + 'Call', + 'StopTransaction', + { transactionId: 99, reason: 'Local' }, + 3000 + 60 * 60 * 1000, + ), + makeEvent( + 'e8', + 'm4', + 'CallResult', + null, + { idTagInfo: { status: 'Accepted' } }, + 3000 + 60 * 60 * 1000 + 500, + 'CSMS_TO_CS', + ), + ]; + + const diff = diffTraces(makeParseResult(eventsA), makeParseResult(eventsB)); + + expect(diff.summaryDiff.differences.length).toBeGreaterThan(0); + expect(diff.summaryDiff.differences.some((d) => d.includes('Transaction ID'))).toBe(true); + expect(diff.summaryDiff.differences.some((d) => d.includes('Duration'))).toBe(true); + }); + + it('handles completely different traces', () => { + const eventsA = [ + makeEvent('a1', 'ma1', 'Call', 'BootNotification', {}, 1000), + makeEvent('a2', 'ma1', 'CallResult', null, { status: 'Accepted' }, 1500, 'CSMS_TO_CS'), + ]; + const eventsB = [ + makeEvent('b1', 'mb1', 'Call', 'Heartbeat', {}, 2000), + makeEvent( + 'b2', + 'mb1', + 'CallResult', + null, + { currentTime: '2024-01-15T10:00:02Z' }, + 2100, + 'CSMS_TO_CS', + ), + ]; + const diff = diffTraces(makeParseResult(eventsA), makeParseResult(eventsB)); + + expect(diff.onlyInA).toHaveLength(2); + expect(diff.onlyInB).toHaveLength(2); + expect(diff.modified).toHaveLength(0); + }); + + it('works with parsed trace input', () => { + const jsonA = JSON.stringify({ + events: [ + { timestamp: '2024-01-15T10:00:00.000Z', message: [2, 'm1', 'BootNotification', {}] }, + { + timestamp: '2024-01-15T10:00:00.500Z', + message: [3, 'm1', { status: 'Accepted', interval: 60 }], + }, + ], + }); + const jsonB = JSON.stringify({ + events: [ + { timestamp: '2024-01-15T10:00:00.000Z', message: [2, 'm1', 'BootNotification', {}] }, + { + timestamp: '2024-01-15T10:00:01.500Z', + message: [3, 'm1', { status: 'Accepted', interval: 60 }], + }, + ], + }); + const resultA = parseTrace(jsonA); + const resultB = parseTrace(jsonB); + const diff = diffTraces(resultA, resultB); + + const timestampMods = diff.modified.filter((m) => m.field === 'timestamp'); + expect(timestampMods).toHaveLength(1); + }); +}); diff --git a/packages/toolkit/src/core/diff.ts b/packages/toolkit/src/core/diff.ts new file mode 100644 index 0000000..64f2405 --- /dev/null +++ b/packages/toolkit/src/core/diff.ts @@ -0,0 +1,251 @@ +/** + * Trace diffing — compare two parsed traces and surface differences. + * + * Matches events by `messageId` (OCPP UniqueId), then compares: + * - timestamp, direction, action, payload (deep equality), errorCode + * - detected failures (by code) + * - session summaries (human-readable difference strings) + * + * Pure function — no I/O, no side effects. Browser-safe (no Node built-ins). + * + * @module @ocpp-debugkit/toolkit/core + */ + +import type { Event, Failure, ParseResult, SessionSummary } from './types.js'; +import { detectFailures } from './detection.js'; +import { buildSessionTimeline } from './timeline.js'; +import { summarizeSessions } from './summarizer.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A field-level difference between two events that share a messageId. */ +export interface EventDiff { + /** The OCPP UniqueId shared by both events. */ + messageId: string; + /** The field that differs. */ + field: 'timestamp' | 'direction' | 'action' | 'payload' | 'errorCode'; + /** The value in trace A (or null if the field was absent). */ + valueA: unknown; + /** The value in trace B (or null if the field was absent). */ + valueB: unknown; +} + +/** Summary-level differences between two traces. */ +export interface SummaryDiff { + /** Summary of trace A (or null if A has no sessions). */ + a: SessionSummary | null; + /** Summary of trace B (or null if B has no sessions). */ + b: SessionSummary | null; + /** Human-readable difference descriptions. */ + differences: string[]; +} + +/** The complete diff result between two traces. */ +export interface TraceDiff { + /** Events present in trace A but not B (by messageId). */ + onlyInA: Event[]; + /** Events present in trace B but not A (by messageId). */ + onlyInB: Event[]; + /** Events present in both but with field-level differences. */ + modified: EventDiff[]; + /** Failures detected in A but not B. */ + failuresOnlyInA: Failure[]; + /** Failures detected in B but not A. */ + failuresOnlyInB: Failure[]; + /** Summary differences. */ + summaryDiff: SummaryDiff; +} + +// --------------------------------------------------------------------------- +// Deep equality check (limited — not a general-purpose deep-equal) +// --------------------------------------------------------------------------- + +/** + * Checks deep equality of two values. Handles primitives, arrays, and plain + * objects. Does not handle Maps, Sets, Dates, or other complex types — + * OCPP payloads are JSON-serializable data. + */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null) return false; + if (typeof a !== typeof b) return false; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, i) => deepEqual(item, b[i])); + } + + if (typeof a === 'object' && typeof b === 'object') { + const objA = a as Record; + const objB = b as Record; + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + if (keysA.length !== keysB.length) return false; + return keysA.every((key) => deepEqual(objA[key], objB[key])); + } + + return false; +} + +// --------------------------------------------------------------------------- +// Diff implementation +// --------------------------------------------------------------------------- + +/** + * Compare two parsed traces and return a structured diff. + * + * @param a - Parse result of trace A + * @param b - Parse result of trace B + * @returns A {@link TraceDiff} describing all differences + * + * @example + * ```typescript + * const resultA = parseTrace(traceAJson); + * const resultB = parseTrace(traceBJson); + * const diff = diffTraces(resultA, resultB); + * + * if (diff.onlyInA.length > 0) { + * console.log(`${diff.onlyInA.length} events only in trace A`); + * } + * ``` + */ +export function diffTraces(a: ParseResult, b: ParseResult): TraceDiff { + const messageIdsA = new Set(a.events.map((e) => e.messageId)); + const messageIdsB = new Set(b.events.map((e) => e.messageId)); + + const onlyInA = a.events.filter((e) => !messageIdsB.has(e.messageId)); + const onlyInB = b.events.filter((e) => !messageIdsA.has(e.messageId)); + // Modified events (present in both but with differences) + // Build maps for comparison — store arrays of events per messageId + const mapA = new Map(); + const mapB = new Map(); + for (const event of a.events) { + if (!messageIdsB.has(event.messageId)) continue; + const existing = mapA.get(event.messageId); + if (existing) { + existing.push(event); + } else { + mapA.set(event.messageId, [event]); + } + } + for (const event of b.events) { + if (!messageIdsA.has(event.messageId)) continue; + const existing = mapB.get(event.messageId); + if (existing) { + existing.push(event); + } else { + mapB.set(event.messageId, [event]); + } + } + + const modified: EventDiff[] = []; + + for (const [messageId, eventsA] of mapA) { + const eventsB = mapB.get(messageId); + if (!eventsB) continue; + + // Compare events at each position within the same messageId + const maxLen = Math.max(eventsA.length, eventsB.length); + for (let i = 0; i < maxLen; i++) { + const eventA = eventsA[i]; + const eventB = eventsB[i]; + if (!eventA || !eventB) continue; + + if (eventA.timestamp !== eventB.timestamp) { + modified.push({ + messageId, + field: 'timestamp', + valueA: eventA.timestamp, + valueB: eventB.timestamp, + }); + } + if (eventA.direction !== eventB.direction) { + modified.push({ + messageId, + field: 'direction', + valueA: eventA.direction, + valueB: eventB.direction, + }); + } + if (eventA.action !== eventB.action) { + modified.push({ messageId, field: 'action', valueA: eventA.action, valueB: eventB.action }); + } + if (!deepEqual(eventA.payload, eventB.payload)) { + modified.push({ + messageId, + field: 'payload', + valueA: eventA.payload, + valueB: eventB.payload, + }); + } + if (eventA.errorCode !== eventB.errorCode) { + modified.push({ + messageId, + field: 'errorCode', + valueA: eventA.errorCode, + valueB: eventB.errorCode, + }); + } + } + } + + // --- Failure diffing --- + + const sessionsA = buildSessionTimeline(a.events); + const sessionsB = buildSessionTimeline(b.events); + const failuresA = detectFailures(a.events, sessionsA); + const failuresB = detectFailures(b.events, sessionsB); + + const failureCodesA = new Set(failuresA.map((f) => f.code)); + const failureCodesB = new Set(failuresB.map((f) => f.code)); + + const failuresOnlyInA = failuresA.filter((f) => !failureCodesB.has(f.code)); + const failuresOnlyInB = failuresB.filter((f) => !failureCodesA.has(f.code)); + + // --- Summary diffing --- + + const summariesA = summarizeSessions(sessionsA, failuresA); + const summariesB = summarizeSessions(sessionsB, failuresB); + + const summaryA = summariesA.length > 0 ? (summariesA[0] as (typeof summariesA)[number]) : null; + const summaryB = summariesB.length > 0 ? (summariesB[0] as (typeof summariesB)[number]) : null; + + const differences: string[] = []; + + if (summaryA && summaryB) { + if (summaryA.eventCount !== summaryB.eventCount) { + differences.push(`Event count: A=${summaryA.eventCount}, B=${summaryB.eventCount}`); + } + if (summaryA.failureCount !== summaryB.failureCount) { + differences.push(`Failure count: A=${summaryA.failureCount}, B=${summaryB.failureCount}`); + } + if (summaryA.status !== summaryB.status) { + differences.push(`Session status: A="${summaryA.status}", B="${summaryB.status}"`); + } + if (summaryA.durationMs !== summaryB.durationMs) { + differences.push(`Duration: A=${summaryA.durationMs}ms, B=${summaryB.durationMs}ms`); + } + if (summaryA.transactionId !== summaryB.transactionId) { + differences.push(`Transaction ID: A=${summaryA.transactionId}, B=${summaryB.transactionId}`); + } + } else if (summaryA && !summaryB) { + differences.push('Trace A has sessions but trace B does not'); + } else if (!summaryA && summaryB) { + differences.push('Trace B has sessions but trace A does not'); + } + + return { + onlyInA, + onlyInB, + modified, + failuresOnlyInA, + failuresOnlyInB, + summaryDiff: { + a: summaryA, + b: summaryB, + differences, + }, + }; +} diff --git a/packages/toolkit/src/core/index.ts b/packages/toolkit/src/core/index.ts index 89b890f..d63ebb8 100644 --- a/packages/toolkit/src/core/index.ts +++ b/packages/toolkit/src/core/index.ts @@ -37,5 +37,9 @@ export { summarizeSession, summarizeSessions } from './summarizer.js'; // Validator export { validateMessage, validateMessages } from './validator.js'; +// Diff +export { diffTraces } from './diff.js'; +export type { TraceDiff, EventDiff, SummaryDiff } from './diff.js'; + // Fixtures export * from './fixtures/index.js'; diff --git a/packages/toolkit/src/index.ts b/packages/toolkit/src/index.ts index b7ded7f..3e8cbda 100644 --- a/packages/toolkit/src/index.ts +++ b/packages/toolkit/src/index.ts @@ -38,6 +38,7 @@ export { buildSessionTimeline } from './core/index.js'; export { detectFailures } from './core/index.js'; export { summarizeSession, summarizeSessions } from './core/index.js'; export { validateMessage, validateMessages } from './core/index.js'; +export { diffTraces } from './core/index.js'; // Re-export core types and schemas export type { @@ -57,6 +58,9 @@ export type { TraceMetadata, ParseWarning, ParseResult, + TraceDiff, + EventDiff, + SummaryDiff, } from './core/index.js'; // Re-export schemas