Skip to main content
ContractLogger gives you a hook into every phase of a contract run — from the first attempt through parsing, verification, repair, retry, and terminal outcome. It’s a structural type (every hook optional), so you only implement the ones you need.
import { defineContract, type ContractLogger } from "@withboundary/contract";

const logger: ContractLogger = {
  onRunStart(ctx) { metrics.increment("contract.start", { name: ctx.contractName }); },
  onRunSuccess(ctx) { metrics.timing("contract.duration", ctx.totalDurationMs); },
  onRunFailure(ctx) { metrics.increment("contract.failure", { category: ctx.category }); },
};

defineContract({ name: "lead-scoring", schema, rules, logger });

Event flow

Every hook receives a context object with contractName so one logger can observe many contracts.

All 10 hooks

onRunStart

onRunStart?: (ctx: {
  contractName: string;
  maxAttempts: number;
  rulesCount: number;
  model?: string;
  retry: { maxAttempts: number; backoff: "none" | "linear" | "exponential"; baseMs: number };
}) => void;
Called once per contract.accept(...) invocation, before any attempt runs.

onAttemptStart

onAttemptStart?: (ctx: {
  contractName: string;
  attempt: number;
  maxAttempts: number;
  instructions: string;
  repairs: Message[];
}) => void;
Called before each attempt. On attempt 1, repairs is empty. On later attempts, it contains the repair messages generated from prior failures.

onRawOutput

onRawOutput?: (ctx: { contractName: string; attempt: number; raw: string }) => void;
The raw string your RunFn returned, before any cleaning or parsing.

onCleanedOutput

onCleanedOutput?: (ctx: { contractName: string; attempt: number; cleaned: unknown }) => void;
The result of clean(raw) — JSON extracted from fences, de-prose’d, etc. Not yet validated.

onVerifySuccess

onVerifySuccess?: (ctx: {
  contractName: string;
  attempt: number;
  data: T;
  durationMs: number;
}) => void;
The attempt passed schema validation and all rules. The overall run will succeed.

onVerifyFailure

onVerifyFailure?: (ctx: {
  contractName: string;
  attempt: number;
  category: string;
  issues: string[];
  durationMs: number;
}) => void;
The attempt failed. category is the FailureCategory. issues is the list of violations.

onRepairGenerated

onRepairGenerated?: (ctx: {
  contractName: string;
  attempt: number;
  category: string;
  repairMessage: string;
}) => void;
Fires after a failure when a repair message has been built. Won’t fire for categories you’ve disabled via repairs: { CATEGORY: false }.

onRetryScheduled

onRetryScheduled?: (ctx: {
  contractName: string;
  attempt: number;
  nextAttempt: number;
  category: string;
  delayMs: number;
}) => void;
Fires after repair, before the backoff delay. delayMs is the computed delay for this retry based on your retry.backoff strategy.

onRunSuccess

onRunSuccess?: (ctx: {
  contractName: string;
  attempts: number;
  data: T;
  totalDurationMs: number;
}) => void;
Terminal success. attempts is the total number of attempts (including the successful one).

onRunFailure

onRunFailure?: (ctx: {
  contractName: string;
  attempts: number;
  category?: string;
  message: string;
  totalDurationMs: number;
}) => void;
Terminal failure — all retries exhausted. category is the last failure’s category (may be undefined if the run errored before any verify).

Recipes

Custom metrics

const logger: ContractLogger = {
  onRunStart(ctx) {
    timer = metrics.startTimer("contract.run", { name: ctx.contractName });
  },
  onVerifyFailure(ctx) {
    metrics.increment("contract.verify.failure", {
      name: ctx.contractName,
      category: ctx.category,
    });
  },
  onRunSuccess(ctx) {
    timer.stop({ ok: "true", attempts: String(ctx.attempts) });
  },
  onRunFailure(ctx) {
    timer.stop({ ok: "false", attempts: String(ctx.attempts) });
  },
};

OpenTelemetry spans

import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("boundary");

const spans = new Map<string, Span>();

const logger: ContractLogger = {
  onRunStart(ctx) {
    const span = tracer.startSpan(`contract.${ctx.contractName}`);
    spans.set(ctx.contractName, span);
  },
  onAttemptStart(ctx) {
    spans.get(ctx.contractName)?.addEvent("attempt.start", { attempt: ctx.attempt });
  },
  onVerifyFailure(ctx) {
    spans.get(ctx.contractName)?.addEvent("verify.failure", {
      category: ctx.category,
      issues: ctx.issues.join("; "),
    });
  },
  onRunSuccess(ctx) {
    const span = spans.get(ctx.contractName);
    span?.setAttribute("ok", true);
    span?.setAttribute("attempts", ctx.attempts);
    span?.end();
    spans.delete(ctx.contractName);
  },
  onRunFailure(ctx) {
    const span = spans.get(ctx.contractName);
    span?.setAttribute("ok", false);
    span?.setAttribute("attempts", ctx.attempts);
    span?.recordException(new Error(ctx.message));
    span?.end();
    spans.delete(ctx.contractName);
  },
};

Structured debug logging

Use the built-in console logger for human-readable traces:
import { createConsoleLogger } from "@withboundary/contract";

defineContract({
  name: "lead-scoring",
  schema,
  rules,
  logger: createConsoleLogger({
    showInstructions: true,
    showRepairs: true,
    showRawOutput: true,
    showCleanedOutput: true,
    maxStringLength: 500,
  }),
});
Or use the shorthand debug: true for default verbosity.

Combining multiple loggers

You can only pass one logger to defineContract. To fan out to both Boundary and your metrics system, compose them manually:
import { createBoundaryLogger } from "@withboundary/sdk";

const boundary = createBoundaryLogger({ apiKey });
const metrics: ContractLogger = {
  onRunFailure(ctx) { statsd.increment("contract.fail"); },
};

const combined: ContractLogger = {
  onRunStart: (ctx) => { boundary?.onRunStart?.(ctx); metrics.onRunStart?.(ctx); },
  onRunFailure: (ctx) => { boundary?.onRunFailure?.(ctx); metrics.onRunFailure?.(ctx); },
  onRunSuccess: (ctx) => { boundary?.onRunSuccess?.(ctx); metrics.onRunSuccess?.(ctx); },
  // ... forward the rest
};
A helper utility for this composition is on the roadmap.

Constraints

  • Hooks are synchronous from the contract loop’s point of view. Returning a promise doesn’t delay the next phase — the loop moves on. Push heavy work into a batch (like createBoundaryLogger does) or a queue.
  • Exceptions inside hooks are caught and swallowed. Your logger cannot break a contract run.
  • Hook order is guaranteed per attempt: onAttemptStartonRawOutputonCleanedOutputonVerifySuccess | (onVerifyFailureonRepairGeneratedonRetryScheduled).

See also

SDK Overview

createBoundaryLogger is a ContractLogger

Engine primitives

Skip the loop, use the pieces directly