Skip to main content
userTourKit
@tour-kit/core

Diagnostic Engine

Replace the silent "tour didn't fire" failure mode with a structured `EligibilityReport` that names every gate's outcome.

domidex01Published

The diagnostic engine produces an EligibilityReport for every registered tour explaining whether it will fire — and, when it won't, exactly which gate rejected it. Opt in via <TourProvider diagnose> and read the result with useTourDiagnostic(tourId).

Opt-in by default

diagnose defaults to false. With it off the orchestrator tree-shakes out of the consumer bundle, so the cost is zero in production. In dev builds the provider logs a one-time hint nudging you to enable it.

Why diagnostics exist

The most-asked support question on this repo is "my tour did not fire and I have no idea why." Before the diagnostic engine you had to grep through audience filters, route adapters, persistence stores, and license keys to guess. Now the provider runs all seven built-in gates plus any extension gates you registered, and the report names the first one that said no.

<TourProvider diagnose>

import { TourProvider, useTourDiagnostic } from '@tour-kit/core'

export function App() {
  return (
    <TourProvider tours={[myTour]} diagnose userContext={{ plan: 'pro' }}>
      <DebugPanel />
      <YourApp />
    </TourProvider>
  )
}

function DebugPanel() {
  const report = useTourDiagnostic('my-tour')
  if (!report) return null
  return (
    <pre>{JSON.stringify(report, null, 2)}</pre>
  )
}

The hook returns:

  • null when diagnose is off, the tour id is unknown, or the diagnostic effect hasn't resolved yet
  • EligibilityReport once the orchestrator finishes

EligibilityReport shape

interface EligibilityReport {
  tourId: string
  willFire: boolean
  reasons: GateReason[]
  firstFailingGate: Extract<GateReason, { ok: false }> | null
  evaluatedAt: number
}

reasons[] is always populated for every built-in gate (in fixed order) plus your extension gates. firstFailingGate is a convenience — the first ok: false reason in evaluation order, or null when willFire is true.

Built-in gates

Evaluated in this exact order — the contract is observable and tests pin it:

OrderGateFailure codeWhen it fires
1structureSTRUCTURE_INVALIDTour has no steps, or validateTour threw
2audienceAUDIENCE_MISMATCHAudience filter rejected the user. detail.failingCondition (array form) or detail.segment (object form) names the failing piece
3persistenceALREADY_COMPLETED / ALREADY_SKIPPEDTour id is in completedTours / skippedTours
4routeROUTE_MISMATCHCurrent route does not match the configured matcher under the chosen mode
5targetTARGET_NOT_FOUNDThe first visible step's selector returned no element
6whenWHEN_RETURNED_FALSETour-level when() returned false or threw
7autostartAUTOSTART_DISABLEDtour.autoStart === false

`structure` short-circuits

When structure fails, no other built-in gate runs — there's nothing meaningful to ask of a malformed tour. Other gates all run to completion so you can see every rejection at once.

Async `when()`

If tour.when returns a Promise, the gate yields ok: true with detail.note documenting that the async path is owned by the runtime pipeline. Diagnostics are synchronous-friendly.

Extension gates

License, scheduling, or any custom gate plugs in via diagnosticGates. The contract is DiagnosticGate@tour-kit/core does not import the package implementing it.

import type { DiagnosticGate } from '@tour-kit/core'

const licenseGate: DiagnosticGate = {
  id: 'license',
  evaluate: async (ctx) => {
    const ok = await checkLicense(ctx.userContext)
    return ok
      ? { ok: true, gate: 'license' }
      : { ok: false, gate: 'license', code: 'LICENSE_INVALID', message: 'License invalid', detail: {} }
  },
}

<TourProvider tours={tours} diagnose diagnosticGates={[licenseGate]}>
  ...
</TourProvider>

Rules:

  • Extensions run after every built-in gate, in registration order
  • Extensions may be async; built-ins are sync
  • A throwing extension is caught — the orchestrator inserts a synthetic { ok: false, gate: id, code: '${ID}_THREW' } reason and keeps going
  • firstFailingGate only points at an extension when no built-in failed first

explainTour (advanced)

The orchestrator is also exported for non-React callers:

import { explainTour } from '@tour-kit/core'

const report = await explainTour(myTour, {
  completedTours: [],
  skippedTours: [],
  userContext: { plan: 'pro' },
  targetResolver: (sel) => document.querySelector(sel),
})

explainTour never throws. Internal errors land in the structure gate or, for extensions, as _THREW reasons.

Performance & bundle cost

  • Median <0.001ms per call over 100 iterations (5-step tour, no extensions)
  • With diagnose: false the diagnostic module tree-shakes out of the consumer bundle. The size-limit budget asserts this.