Source: lib/core/trace/recorder.js

/**
 * @file recorder.js
 * @description The trace recorder: a bounded buffer plus determinism bookkeeping.
 *
 * The recorder owns everything a trace needs that the {@link tracer} hook
 * surface deliberately does not: node ids, timestamps, the ring buffer, the
 * redaction rules, the log of non-deterministic globals, and the running
 * judgement of whether this session could be replayed.
 *
 * That last part is the reason this class exists rather than an array. A trace
 * that claims to be deterministic and is not produces a regression test that
 * passes for the wrong reason, which is worse than no test. The recorder
 * therefore downgrades eagerly and never upgrades.
 * @module lib/core/trace/recorder
 */

import { captureValue, wasLossy } from './capture.js';
import { Redactor } from './redact.js';
import { tracer } from './tracer.js';
import {
  TRACE_VERSION,
  TraceNodeType,
  Determinism,
  NonDeterminismReason,
  createTrace,
} from './schema.js';

/**
 * How many nodes a recording keeps before it starts dropping the oldest.
 *
 * A minute of interaction on a busy list produces a lot of nodes; an unbounded
 * array would be a memory leak in a dev server that stays open all day. When
 * the buffer wraps, the trace is marked truncated and therefore best-effort,
 * because it no longer starts at the beginning of the session.
 * @type {number}
 */
export const DEFAULT_MAX_NODES = 5000;

/**
 * How deep the serialization scrub walks a node.
 *
 * Captured values are already bounded by {@link captureValue}, so this only has
 * to match that ceiling.
 * @type {number}
 */
const SCRUB_MAX_DEPTH = 8;

/**
 * Where a recorder is in its lifecycle.
 *
 * Writes during `setup` are the application constructing itself and are not
 * evidence of non-determinism. Writes during `recording` with nothing on the
 * causal stack are: something outside the recorded inputs changed state.
 * @readonly
 * @enum {string}
 */
export const RecorderPhase = {
  SETUP: 'setup',
  RECORDING: 'recording',
  STOPPED: 'stopped',
};

/**
 * Generates a short, readable trace id.
 * @returns {string} An id such as `trace-4f2a`.
 */
function generateId() {
  const suffix = Math.floor(Math.random() * 0xffff)
    .toString(16)
    .padStart(4, '0');
  return `trace-${suffix}`;
}

/**
 * Reads a monotonic clock, falling back where `performance` is absent.
 * @returns {number} Milliseconds from an arbitrary origin.
 */
function now() {
  if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
    return performance.now();
  }
  return Date.now();
}

/**
 * Records one session's causal trace.
 */
export class TraceRecorder {
  /**
   * @param {object} [options] - Recorder options.
   * @param {string} [options.id] - An explicit trace id. Generated when omitted.
   * @param {number} [options.maxNodes] - Ring-buffer capacity.
   * @param {string[]} [options.redact] - Redaction patterns.
   * @param {object} [options.meta] - Free-form metadata about the session.
   */
  constructor(options = {}) {
    /** @type {string} */
    this.id = options.id || generateId();
    /** @type {number} */
    this.maxNodes = options.maxNodes || DEFAULT_MAX_NODES;
    /** @type {Redactor} */
    this.redactor = new Redactor(options.redact || []);
    /** @type {object} */
    this.meta = { ...(options.meta || {}) };

    /**
     * The ring buffer. Kept as a plain array that is spliced in one block when
     * it overflows, rather than a circular index: traces are read far more
     * often than they overflow, and a linear array keeps every reader simple.
     * @type {object[]}
     */
    this.nodes = [];

    /**
     * Open nodes by id, so {@link TraceRecorder#annotate} can find a node the
     * buffer may since have dropped.
     * @type {Map<number, object>}
     */
    this.index = new Map();

    /** @type {number} */
    this.nextId = 1;
    /** @type {number} */
    this.seq = 0;
    /** @type {number} */
    this.dropped = 0;
    /** @type {number} */
    this.startedAt = now();
    /** @type {string} */
    this.createdAt = new Date().toISOString();
    /** @type {string} */
    this.phase = RecorderPhase.SETUP;

    /**
     * Non-deterministic global values in the order application code observed
     * them, so replay can hand back the same sequence.
     * @type {{now: number[], random: number[]}}
     */
    this.globals = { now: [], random: [] };

    /** @type {string} */
    this.determinism = Determinism.DETERMINISTIC;
    /** @type {Map<string, string>} */
    this.reasons = new Map();
  }

  /**
   * Switches from application startup to recording user interaction.
   *
   * Until this is called, state writes with no recorded cause are treated as
   * the application initialising itself rather than as non-determinism.
   * @returns {TraceRecorder} This recorder.
   */
  arm() {
    this.phase = RecorderPhase.RECORDING;
    return this;
  }

  /**
   * Ends the recording.
   * @returns {TraceRecorder} This recorder.
   */
  stop() {
    this.phase = RecorderPhase.STOPPED;
    return this;
  }

  /**
   * Appends a node.
   * @param {string} type - A {@link TraceNodeType}.
   * @param {object} data - Type-specific fields.
   * @param {number|null} parent - The causal parent id.
   * @returns {object} The stored node.
   */
  push(type, data, parent) {
    const node = {
      id: this.nextId++,
      parent: parent === undefined ? null : parent,
      seq: ++this.seq,
      t: Math.round((now() - this.startedAt) * 1000) / 1000,
      type,
      ...data,
    };

    this.nodes.push(node);
    this.index.set(node.id, node);

    if (type === TraceNodeType.WRITE && parent === null && this.phase === RecorderPhase.RECORDING) {
      this.markNonDeterministic(
        NonDeterminismReason.UNATTRIBUTED_WRITE,
        `${data.path || 'unknown path'} changed with no recorded input to explain it`,
      );
    }

    if (this.nodes.length > this.maxNodes) {
      this.#evict();
    }

    return node;
  }

  /**
   * Drops the oldest tenth of the buffer.
   *
   * Evicting in blocks rather than one node at a time keeps the amortised cost
   * of a splice off the hot path once a long session settles into steady state.
   * @private
   */
  #evict() {
    const removeCount = Math.max(1, Math.floor(this.maxNodes / 10));
    const removed = this.nodes.splice(0, removeCount);
    for (const node of removed) {
      this.index.delete(node.id);
    }
    this.dropped += removed.length;
    this.markNonDeterministic(
      NonDeterminismReason.TRUNCATED,
      `${this.dropped} node(s) dropped; the buffer holds ${this.maxNodes}`,
    );
  }

  /**
   * Merges fields into a node that is still in the buffer.
   * @param {number} id - The node id.
   * @param {object} fields - Fields to merge.
   */
  annotate(id, fields) {
    const node = this.index.get(id);
    if (node) {
      Object.assign(node, fields);
    }
  }

  /**
   * Captures a value under this recorder's redaction and bounds.
   * @param {any} value - The value.
   * @param {string} [path] - The property path, for redaction matching.
   * @returns {any} A JSON-representable capture.
   */
  capture(value, path = '') {
    const captured = captureValue(value, { path, redactor: this.redactor });
    if (wasLossy()) {
      this.markNonDeterministic(
        NonDeterminismReason.UNSERIALIZABLE_VALUE,
        path ? `at ${path}` : 'a recorded value could not be represented in JSON',
      );
    }
    return captured;
  }

  /**
   * Records a non-deterministic global value the sandbox handed to application
   * code, so replay can hand back the same one.
   * @param {'now'|'random'} kind - Which source produced it.
   * @param {number} value - The value.
   * @returns {number} The same value, so callers can record inline.
   */
  recordGlobal(kind, value) {
    const log = this.globals[kind];
    if (log) {
      log.push(value);
    }
    return value;
  }

  /**
   * Downgrades the trace to best-effort.
   *
   * Reasons are de-duplicated by kind: a session that made a thousand
   * unattributed writes should say so once, with an example.
   * @param {string} reason - A {@link NonDeterminismReason}.
   * @param {string} [detail] - Context for the developer.
   */
  markNonDeterministic(reason, detail = '') {
    this.determinism = Determinism.BEST_EFFORT;
    if (!this.reasons.has(reason)) {
      this.reasons.set(reason, detail);
    }
  }

  /**
   * Whether this recording is currently believed to be replayable.
   *
   * "Believed" is doing real work in that sentence: this reflects only the
   * escapes the runtime could observe. Replay verifies the claim independently
   * by comparing what it observes against what was recorded.
   * @returns {boolean}
   */
  get isDeterministic() {
    return this.determinism === Determinism.DETERMINISTIC;
  }

  /**
   * How many distinct components appear in the trace.
   * @returns {number}
   */
  get componentCount() {
    const seen = new Set();
    for (const node of this.nodes) {
      if (node.component) {
        seen.add(node.component);
      }
    }
    return seen.size;
  }

  /**
   * Produces the serializable trace.
   * @returns {object} A trace matching {@link TRACE_VERSION}.
   */
  toJSON() {
    const trace = createTrace(this.meta);
    trace.traceVersion = TRACE_VERSION;
    trace.id = this.id;
    trace.createdAt = this.createdAt;
    trace.dropped = this.dropped;
    trace.globals = { now: [...this.globals.now], random: [...this.globals.random] };
    trace.redactions = [...this.redactor.patterns];
    trace.redacted = this.redactor.applied;
    trace.determinism = {
      status: this.determinism,
      reasons: [...this.reasons.entries()].map(([reason, detail]) => ({ reason, detail })),
    };
    // Withheld values are scrubbed here rather than when a node is recorded: an
    // action's body and a call's arguments are captured before the writes that
    // identify a value as a secret, so at record time it is not yet known to be
    // one. Serialization is the moment the trace leaves this process, which
    // makes it the right last line of defence.
    trace.nodes = this.redactor.withheldValues.size > 0 ? this.nodes.map((node) => this.#scrubNode(node)) : this.nodes;
    return trace;
  }

  /**
   * Returns a copy of a node with withheld values removed from its source text.
   * @param {object} node - The node.
   * @returns {object} The node, or a scrubbed copy of it.
   * @private
   */
  #scrubNode(node) {
    return this.#scrubValue(node, SCRUB_MAX_DEPTH);
  }

  /**
   * Replaces withheld values anywhere inside a captured structure.
   *
   * Path rules are the primary mechanism and this is the backstop: a value the
   * rules withheld in one place must not survive somewhere the rules did not
   * anticipate — an action's verbatim source, or the arguments of a call that
   * was recorded before the write that identified the value as a secret.
   *
   * The original object is returned untouched when nothing changed, so a trace
   * with no matches is not needlessly copied.
   * @param {any} value - The value to scrub.
   * @param {number} depth - Remaining depth budget.
   * @returns {any} The value, or a scrubbed copy.
   * @private
   */
  #scrubValue(value, depth) {
    if (typeof value === 'string') {
      return this.redactor.scrub(value);
    }
    if (depth <= 0 || value === null || typeof value !== 'object') {
      return value;
    }

    if (Array.isArray(value)) {
      let copy = value;
      for (let i = 0; i < value.length; i++) {
        const scrubbed = this.#scrubValue(value[i], depth - 1);
        if (scrubbed !== value[i]) {
          if (copy === value) {
            copy = [...value];
          }
          copy[i] = scrubbed;
        }
      }
      return copy;
    }

    let copy = value;
    for (const key of Object.keys(value)) {
      const scrubbed = this.#scrubValue(value[key], depth - 1);
      if (scrubbed !== value[key]) {
        if (copy === value) {
          copy = { ...value };
        }
        copy[key] = scrubbed;
      }
    }
    return copy;
  }

  /**
   * Serializes the trace to a JSON string.
   * @param {number} [indent] - Indentation passed to `JSON.stringify`.
   * @returns {string}
   */
  serialize(indent = 0) {
    return JSON.stringify(this.toJSON(), null, indent);
  }
}

/**
 * Starts recording, replacing any recording already in progress.
 * @param {object} [options] - Options forwarded to {@link TraceRecorder}.
 * @returns {TraceRecorder} The active recorder.
 */
export function startRecording(options = {}) {
  const recorder = new TraceRecorder(options);
  tracer.attach(recorder);
  return recorder;
}

/**
 * Stops the active recording.
 * @returns {object|null} The finished trace, or null if nothing was recording.
 */
export function stopRecording() {
  const recorder = tracer.detach();
  if (!recorder) {
    return null;
  }
  recorder.stop();
  return recorder.toJSON();
}

/**
 * The recorder currently attached to the tracer, if any.
 * @returns {TraceRecorder|null}
 */
export function activeRecorder() {
  return tracer.sink;
}