All files / core/semantic-log/src fingerprint.ts

44.79% Statements 43/96
100% Branches 13/13
66.66% Functions 4/6
44.79% Lines 43/96

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 971x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                                                                                                     1x 1x 10x       15374x 15374x 15374x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 15364x 15364x 15364x 15364x 15364x 15364x 15364x 15364x 15364x  
/**
 * Hybrid serialization and hash-first identity (PRD R2, R3, R4).
 *
 * The identity input is a deterministic prefixed string rather than JSON: the
 * categorical fields sit in fixed positions so they are compared as structure,
 * while free text is masked (PRD R1) and contributes values-stripped content.
 */
 
import {createHash} from 'node:crypto';
import {mask} from './normalize.ts';
import type {LogRecord} from './record.ts';
import {REF_LENGTH} from './refs.ts';
import {compactStack} from './stack.ts';
 
/** Build the deterministic structural string a record's identity is derived from. */
export function serializeForIdentity(record: LogRecord): string {
    const parts = [
        `[LEVEL: ${String(record.levelName).toUpperCase()}]`,
        `[SERVICE: ${record.service}]`,
    ];
    if (record.context) {
        parts.push(`[CONTEXT: ${record.context}]`);
    }
    if (record.operation) {
        parts.push(`[OP: ${record.operation}]`);
    }
    parts.push(`[MSG: ${mask(record.msg)}]`);
    if (record.err) {
        if (record.err.type) {
            parts.push(`[ERR: ${record.err.type}]`);
        }
        if (record.err.message) {
            parts.push(`[ERR_MSG: ${mask(record.err.message)}]`);
        }
        if (record.err.stack) {
            // Masked as well as compacted, and for the same reason: a stack names the
            // files the frames live in, so an identity that kept their paths would
            // differ between two machines running the same code — `/home/kalin/...`
            // locally and `/home/runner/...` in CI — and the same failure would file
            // itself as two templates whose fingerprints (and so their rows on the
            // template page) never match. Depth and line numbers are dropped for the
            // same class of reason (PRD R2); the tokens are what MSG and ERR_MSG
            // already go through. `mask` runs *after* compaction because it flattens
            // whitespace, and compaction is what reads the newlines.
            parts.push(`[STACK: ${mask(compactStack(record.err.stack))}]`);
        }
    }
    // A caller's `redact` pattern can replace the rationale itself
    // (`decision`, `**`) or just its candidate list (`decision.candidates`,
    // `decision.*`) with the `[redacted]` placeholder before identity is minted
    // — redaction runs first, see `logger.ts`. The slot is then no longer a
    // `Decision`, and identity minting must not throw on it: a configured
    // redaction pattern must never turn a log call into a crash. A collapsed
    // slot contributes nothing at all (the rationale is withheld, so no part of
    // it may shape the identity); a collapsed candidate list is omitted while
    // the discriminator and the chosen branch still contribute.
    const {decision} = record;
    if (decision && typeof decision === 'object') {
        parts.push(`[DECISION: ${decision.discriminator}=${decision.chosen}]`);
        if (Array.isArray(decision.candidates)) {
            parts.push(`[CANDIDATES: ${decision.candidates.join(',')}]`);
        }
    }
    return parts.join(' ');
}

/** Hash the structural form. Exact identity, no model involved (PRD R3). */
export function fingerprint(record: LogRecord): string {
    return hashTemplate(serializeForIdentity(record));
}

/** The one place the 32-hex identity is cut from the structural form. */
function hashTemplate(template: string): string {
    return createHash('sha256').update(template).digest('hex').slice(0, 32);
}
 
/**
 * Return a copy of the record with its identity fields filled in.
 *
 * `refs.template` is assigned here, from the fingerprint, rather than by the
 * logger: the template reference must be minted offline by the emitter, which
 * is what makes PRD R19 ("minted locally at emit time") and PRD R12 ("stable
 * across deploys") true without a round trip to the cluster service. The cut
 * length is the shared `REF_LENGTH`, so the reference emitted here and the key
 * the service registry files it under are the same cut by construction.
 */
export function withIdentity(record: LogRecord): LogRecord {
    const template = serializeForIdentity(record);
    const digest = hashTemplate(template);
    return {
        ...record,
        template,
        fingerprint: digest,
        refs: {...record.refs, template: digest.slice(0, REF_LENGTH)},
    };
}