diff --git a/README.md b/README.md index 7d6b4c1..f0d3a39 100644 --- a/README.md +++ b/README.md @@ -543,9 +543,9 @@ The effective serializer's `load` method therefore runs a second time for a samp Shadow metrics use the bounded outcomes `match`, `mismatch`, `superseded`, `filled`, `fill_blocked`, `fill_error`, `redis_error`, `source_error`, `deserialization_error`, `comparison_error`, `confirmation_error`, `timeout`, and `dropped`. `redis_error` applies to the initial shadow-only `C0` read; `confirmation_error` applies to `C1`. A clean `C0` miss is an ordinary `miss{layer="remote_shadow"}` and terminates with a fill, source, or timeout outcome rather than a second shadow outcome for the miss itself. Labels never contain cache ids, values, payloads, Redis keys, or raw exception text. -Confirmed-mismatch logging is separately opt-in through `shadow.logMismatches`; it never replaces the required `shadowValidation` metric or emits for a mismatch candidate that becomes `superseded`. The single warning contains `cacheNamespace`, `useCase`, `keyType`, `outcome: "mismatch"`, `cacheKey`, `cachedValueJson`, and `sourceValueJson`. `cacheKey` is the logical DialCache URN capped at 2 KiB, not the physical Redis storage key. DialCache independently applies native `JSON.stringify` to the deserialized cached snapshot and raw source value supplied to the comparator, then caps each resulting string at 8 KiB. A byte-clipped field ends in `...[truncated]`, counted inside its cap. If native JSON throws or returns `undefined`, the corresponding JSON field is `null`; the other side is still attempted. DialCache does not compute a textual diff or call the configured serializer again for logging. +Confirmed-mismatch logging is separately opt-in through `shadow.logMismatches`; it never replaces the required `shadowValidation` metric or emits for a mismatch candidate that becomes `superseded`. The single warning contains `cacheNamespace`, `useCase`, `keyType`, `outcome: "mismatch"`, `cacheKey`, `cachedValueJson`, and `sourceValueJson`. `cacheKey` is the logical DialCache URN capped at 2 KiB, not the physical Redis storage key. DialCache independently applies native `JSON.stringify` to the deserialized cached snapshot and raw source value supplied to the comparator, then caps each resulting string at 8 KiB. A byte-clipped field ends in `...[truncated]`, counted inside its cap. If native JSON throws or returns `undefined`, the corresponding JSON field is `null`; the other side is still attempted. `Buffer` and other `Uint8Array` views are replaced by a `""` marker instead of the per-byte decimal array native JSON would produce, at any depth; a plain record already shaped like that native result is reduced the same way. DialCache does not compute a textual diff or call the configured serializer again for logging. -Mismatch logging is intentionally default-off. Logical URNs can contain ids and arguments, while cached and source values may contain secrets or personal data; truncation is not redaction. DialCache creates the JSON strings only after terminal mismatch confirmation and never passes the raw compared-value references to the logger. Native JSON semantics apply: getters and `toJSON` methods may run, unsupported values may be omitted or normalized, and cycles or `bigint` can make a field unavailable. Stringification is synchronous, and the 8 KiB caps apply only after `JSON.stringify` returns; they do not bound input traversal, hook execution, event-loop time, or the intermediate JSON string. Enable mismatch logging only for trusted, reasonably bounded values and with an approved logger, redaction, transport, access, and retention policy. +Mismatch logging is intentionally default-off. Logical URNs can contain ids and arguments, while cached and source values may contain secrets or personal data; truncation is not redaction. DialCache creates the JSON strings only after terminal mismatch confirmation and never passes the raw compared-value references to the logger. Native JSON semantics apply: getters and `toJSON` methods may run, unsupported values may be omitted or normalized, and cycles or `bigint` can make a field unavailable. Stringification is synchronous, and the 8 KiB caps apply only after `JSON.stringify` returns; they do not bound input traversal, hook execution, event-loop time, or the intermediate JSON string. The binary marker keeps byte arrays out of that intermediate string, but a nested `Buffer` still runs its own `toJSON` before DialCache can replace it. Enable mismatch logging only for trusted, reasonably bounded values and with an approved logger, redaction, transport, access, and retention policy. The byte caps apply before logger framing or escaping, so they do not guarantee a final transport event below a sink-specific limit; the metadata fields are not size-clamped. A detail-construction failure degrades to the metadata-only warning. Logger throws and rejected promises or thenables remain isolated from cache and shadow correctness. diff --git a/src/internal/shadow-log-json.ts b/src/internal/shadow-log-json.ts index a43b2db..1b51b08 100644 --- a/src/internal/shadow-log-json.ts +++ b/src/internal/shadow-log-json.ts @@ -18,13 +18,41 @@ export function previewShadowLogKey(value: string): string { export function previewShadowLogJson(value: unknown): string | null { try { - const json = JSON.stringify(value); + if (value instanceof Uint8Array) { + // Skipped before `Buffer.prototype.toJSON` can expand the whole view. + return JSON.stringify(binaryMarker(value.byteLength)); + } + const json = JSON.stringify(value, replaceBinary); return json === undefined ? null : clampUtf8(json, SHADOW_LOG_VALUE_MAX_BYTES); } catch { return null; } } +/** + * Byte arrays carry no diagnostic value as JSON: native semantics expand them + * into one decimal element per byte, so a clamped preview is a run of digits. + * Plain views are replaced before expansion; `Buffer` runs its own `toJSON` + * first, so it is recognized from that result instead. + */ +function replaceBinary(_key: string, value: unknown): unknown { + if (value instanceof Uint8Array) { + return binaryMarker(value.byteLength); + } + return isBufferJson(value) ? binaryMarker(value.data.length) : value; +} + +function isBufferJson(value: unknown): value is { readonly data: readonly unknown[] } { + return typeof value === "object" + && value !== null + && (value as { type?: unknown }).type === "Buffer" + && Array.isArray((value as { data?: unknown }).data); +} + +function binaryMarker(byteLength: number): string { + return ``; +} + export function shadowMismatchLogDetails( cacheKey: string, cachedValue: unknown, diff --git a/test/shadow-log-json.test.ts b/test/shadow-log-json.test.ts index 22b1707..80dac59 100644 --- a/test/shadow-log-json.test.ts +++ b/test/shadow-log-json.test.ts @@ -45,6 +45,42 @@ describe("shadow mismatch log JSON", () => { expect(previewShadowLogJson(value)).toBeNull(); }); + it("replaces byte arrays with a length marker instead of per-byte decimals", () => { + expect(previewShadowLogJson(Buffer.from("hi"))).toBe('""'); + expect(previewShadowLogJson(new Uint8Array([1, 2, 3]))).toBe('""'); + expect(previewShadowLogJson({ id: "1", blob: Buffer.from("hi") })) + .toBe('{"id":"1","blob":""}'); + expect(previewShadowLogJson({ blob: new Uint8Array([7, 7]) })) + .toBe('{"blob":""}'); + expect(previewShadowLogJson([Buffer.alloc(4)])).toBe('[""]'); + }); + + it("keeps a large byte array from consuming the whole value preview", () => { + const preview = previewShadowLogJson({ id: "1", blob: Buffer.alloc(200_000, 7) }); + + expect(preview).toBe('{"id":"1","blob":""}'); + expect(preview).not.toContain("7,7"); + expect(preview!.endsWith(SHADOW_LOG_TRUNCATION_MARKER)).toBe(false); + }); + + it("reports byte length for views over a shared or offset buffer", () => { + const pooled = Buffer.from("abcd"); + const view = new Uint8Array(new ArrayBuffer(64), 8, 16); + + expect(previewShadowLogJson({ pooled })).toBe('{"pooled":""}'); + expect(previewShadowLogJson({ view })).toBe('{"view":""}'); + }); + + it("leaves plain records that merely look like serialized buffers recognizable", () => { + // Native JSON already renders a real Buffer this way, so collapsing the + // shape is the same reduction rather than a loss of distinct information. + expect(previewShadowLogJson({ type: "Buffer", data: [1, 2] })).toBe('""'); + expect(previewShadowLogJson({ type: "Buffer", data: "not-an-array" })) + .toBe('{"type":"Buffer","data":"not-an-array"}'); + expect(previewShadowLogJson({ type: "Other", data: [1, 2] })) + .toBe('{"type":"Other","data":[1,2]}'); + }); + it("byte-clamps keys and JSON without splitting UTF-8 sequences", () => { const key = `${"k".repeat(SHADOW_LOG_KEY_MAX_BYTES)}🙂`; const value = { text: "🙂".repeat(SHADOW_LOG_VALUE_MAX_BYTES) };