> ## Documentation Index
> Fetch the complete documentation index at: https://docs.withboundary.com/llms.txt
> Use this file to discover all available pages before exploring further.

# ContractLogger Hooks

> Tap into every step of the contract execution loop

`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.

```typescript theme={null}
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

```mermaid theme={null}
flowchart TD
    start[onRunStart] --> atStart[onAttemptStart]
    atStart --> runFn[your RunFn executes]
    runFn --> raw[onRawOutput]
    raw --> cleaned[onCleanedOutput]
    cleaned --> verify{verify schema + rules}
    verify -- pass --> vOk[onVerifySuccess]
    vOk --> runOk[onRunSuccess]
    verify -- fail --> vFail[onVerifyFailure]
    vFail --> repair[onRepairGenerated]
    repair --> retry{attempts left?}
    retry -- yes --> sched[onRetryScheduled]
    sched --> atStart
    retry -- no --> runFail[onRunFailure]
```

Every hook receives `contractName` and `runHandle`. Use `contractName` to group by contract and `runHandle` to correlate hooks from the same run.

## All 10 hooks

### `onRunStart`

```typescript theme={null}
onRunStart?: (ctx: {
  contractName: string;
  runHandle: string;
  maxAttempts: number;
  rulesCount: number;
  model?: string;
  retry: { maxAttempts: number; backoff: "none" | "linear" | "exponential"; baseMs: number };
  schema?: SchemaField[];
  rules?: RuleDefinition[];
}) => void;
```

Called once per `contract.accept(...)` invocation, before any attempt runs.

### `onAttemptStart`

```typescript theme={null}
onAttemptStart?: (ctx: {
  contractName: string;
  runHandle: string;
  attempt: number;
  maxAttempts: number;
  instructions: string;
  repairs: Array<unknown>;
}) => void;
```

Called before each attempt. On attempt 1, `repairs` is empty. On later attempts, it contains the repair messages generated from prior failures.

### `onRawOutput`

```typescript theme={null}
onRawOutput?: (ctx: { contractName: string; runHandle: string; attempt: number; raw: string }) => void;
```

The raw string your `RunFn` returned, before any cleaning or parsing.

### `onCleanedOutput`

```typescript theme={null}
onCleanedOutput?: (ctx: { contractName: string; runHandle: string; attempt: number; cleaned: unknown }) => void;
```

The result of `clean(raw)` — JSON extracted from fences, de-prose'd, etc. Not yet validated.

### `onVerifySuccess`

```typescript theme={null}
onVerifySuccess?: (ctx: {
  contractName: string;
  runHandle: string;
  attempt: number;
  data: T;
  durationMs: number;
}) => void;
```

The attempt passed schema validation **and** all rules. The overall run will succeed.

### `onVerifyFailure`

```typescript theme={null}
onVerifyFailure?: (ctx: {
  contractName: string;
  runHandle: string;
  attempt: number;
  category: string;
  issues: string[];
  ruleIssues?: RuleIssue[];
  durationMs: number;
}) => void;
```

The attempt failed. `category` is the `FailureCategory`. `issues` is the list of violations. On `RULE_ERROR`, `ruleIssues` includes structured rule names and fields.

### `onRepairGenerated`

```typescript theme={null}
onRepairGenerated?: (ctx: {
  contractName: string;
  runHandle: 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`

```typescript theme={null}
onRetryScheduled?: (ctx: {
  contractName: string;
  runHandle: 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`

```typescript theme={null}
onRunSuccess?: (ctx: {
  contractName: string;
  runHandle: string;
  attempts: number;
  data: T;
  totalDurationMs: number;
}) => void;
```

Terminal success. `attempts` is the total number of attempts (including the successful one).

### `onRunFailure`

```typescript theme={null}
onRunFailure?: (ctx: {
  contractName: string;
  runHandle: 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

```typescript theme={null}
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

```typescript theme={null}
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.runHandle, span);
  },
  onAttemptStart(ctx) {
    spans.get(ctx.runHandle)?.addEvent("attempt.start", { attempt: ctx.attempt });
  },
  onVerifyFailure(ctx) {
    spans.get(ctx.runHandle)?.addEvent("verify.failure", {
      category: ctx.category,
      issues: ctx.issues.join("; "),
    });
  },
  onRunSuccess(ctx) {
    const span = spans.get(ctx.runHandle);
    span?.setAttribute("ok", true);
    span?.setAttribute("attempts", ctx.attempts);
    span?.end();
    spans.delete(ctx.runHandle);
  },
  onRunFailure(ctx) {
    const span = spans.get(ctx.runHandle);
    span?.setAttribute("ok", false);
    span?.setAttribute("attempts", ctx.attempts);
    span?.recordException(new Error(ctx.message));
    span?.end();
    spans.delete(ctx.runHandle);
  },
};
```

### Structured debug logging

Use the built-in console logger for human-readable traces:

```typescript theme={null}
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 with a tiny helper:

```typescript theme={null}
import type { ContractLogger } from "@withboundary/contract";
import { createBoundaryLogger } from "@withboundary/sdk";

// Walk every hook on every logger; missing hooks are skipped automatically.
function fanout<T>(...loggers: (ContractLogger<T> | null | undefined)[]): ContractLogger<T> {
  const live = loggers.filter((l): l is ContractLogger<T> => !!l);
  return new Proxy({} as ContractLogger<T>, {
    get(_, hook: string) {
      return (ctx: unknown) => {
        for (const l of live) {
          (l as Record<string, ((c: unknown) => void) | undefined>)[hook]?.(ctx);
        }
      };
    },
  });
}

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

const logger = fanout(
  createBoundaryLogger({ apiKey: process.env.BOUNDARY_API_KEY }),
  metrics,
);

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

The proxy forwards every hook to every logger, skipping `null` (the dev-mode SDK fallback) and skipping any logger that doesn't implement that hook. Drop it into your codebase as-is.

## 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: `onAttemptStart` → `onRawOutput` → `onCleanedOutput` → `onVerifySuccess` | (`onVerifyFailure` → `onRepairGenerated` → `onRetryScheduled`).

## See also

<CardGroup cols={2}>
  <Card title="SDK Overview" icon="chart-line" href="/sdk/overview">
    createBoundaryLogger is a ContractLogger
  </Card>

  <Card title="Engine primitives" icon="gears" href="/guides/engine-primitives">
    Skip the loop, use the pieces directly
  </Card>
</CardGroup>
