Skip to content

Observability

Because every dependency is resolved through one system, that system is also the place to cross-cut them all — logging, timing, correlation ids, snapshots — with no change to the business code.

Use it when you need to see what your app is doing in production, or to connect craft to your monitoring stack. Start with Console: it is yieldable, so overriding it once redirects every log in the app.

The same DI system that powers craftService also lets you cross-cut every crafted function with side effects — logging, snapshots, correlation tracking, timing, error reporting — without touching the business code.

Mental Model

craft-ng distinguishes two kinds of failures:

  • Expected errors: handled explicitly with craftException in your business code.
  • Unexpected errors: bugs. They should never happen — and if they do, they should never happen again.

Unexpected errors are exactly where observability shines. Since they are supposed to be impossible, you want to capture the maximum amount of context the moment one is thrown: stack, app state, correlation chain, etc. That context can then be shipped to a log server, an alerting pipeline, or directly to an AI webhook for triage.

The three pillars craft-ng exposes for that are:

provideFnWrapper

provideFnWrapper lets you wrap every generator-based function executed by craft-ng (services, methods, async processes, queries, mutations, effects…). It is the single best entry point to add cross-cutting side effects.

Basic use case — log any unexpected error to the console:

ts
import { craftAppConfig, provideFnWrapper, Console } from '@craft-ng/core';

export const appConfig = craftAppConfig({
  // ...
  providers: [
    provideFnWrapper(
      'Warning: dependency injection here is not type-safe and may fail at runtime',
      function* (factory, thisArg, args) {
        try {
          return yield* factory.apply(thisArg, args);
        } catch (error) {
          yield* Console.error(error);
          throw error;
        }
      },
    ),
  ],
});

You can register multiple wrappers — they compose. The first registered is the outermost.

provideTemplateTrace

provideTemplateTrace is the render-specific counterpart to provideFnWrapper. It runs synchronously around the children produced by an effective render, including component templates, reactive updates, blocks, projections, deferred branches, and nested callbacks.

ts
import { provideTemplateTrace } from '@craft-ng/core';

provideTemplateTrace((context, next) => {
  const start = performance.now();
  try {
    return next();
  } finally {
    console.debug(
      context.phase,
      context.componentName,
      performance.now() - start,
    );
  }
});

The context contains the render unit (component, block, projection, defer, or callback), its phase (create, initialRender, update, or destroy), the optional component/unit names, and the owning component's renderCount. Wrappers compose in registration order and execute in the current render injector, so component-scoped providers remain injectable.

The wrapper can return different children or return an empty children value without calling next() to replace or block a render. Errors propagate to the normal Craft render error boundary.

provideCraftRouterTrace

provideCraftRouterTrace traces both the Angular Router event stream and the Craft outlet's non-blocking route chain. The latter exposes match, guard, and resolve stages, including reactive guard re-evaluation.

ts
import { provideCraftRouterTrace } from '@craft-ng/core';

provideCraftRouterTrace((context, next) => {
  console.log('[router:start]', context);
  const result = next();
  console.log('[router:end]', context);
  return result;
});

Multiple wrappers compose in registration order. The wrapper must call next() to preserve the navigation or route-chain work.

provideCraftHttpTrace

provideCraftHttpTrace wraps the actual thenable request produced by CraftHttpClient, after its method, URL, params, and payload have been built. It is therefore useful for timing, request logging, redaction, and error reporting without changing feature code.

ts
import { provideCraftHttpTrace } from '@craft-ng/core';

provideCraftHttpTrace(async (context, next) => {
  const start = performance.now();
  try {
    return await next();
  } finally {
    console.log(context.method, context.url, performance.now() - start);
  }
});

Important: injection inside provideFnWrapper is not type-safe

The wrapper body runs in the injection context where the error was raised, not where the wrapper was declared. That makes it extremely practical: you can yield browser boundaries, inject host-tagged metadata, read the offending service's correlation id, etc.

But it has two consequences:

  • injections inside the wrapper are not type-safecraft-ng cannot prove statically that the dependency you ask for is actually provided where the wrapper runs
  • the wrapper is therefore a risky place to do business work

TIP

Use provideFnWrapper mostly for side effects — logging, metrics, snapshots, correlation propagation. Avoid pulling business state through it.

Example: timing every craft function

ts
import { craftAppConfig, provideFnWrapper, HostTag } from '@craft-ng/core';

provideFnWrapper(
  'Warning: dependency injection here is not type-safe and may fail at runtime',
  function* (factory, thisArg, args) {
    const start = performance.now();
    try {
      return yield* factory.apply(thisArg, args);
    } finally {
      const name = yield* HostTag();
      console.log(`${name} took ${performance.now() - start}ms`);
    }
  },
);

provideTakeAppSnapshot

provideTakeAppSnapshot captures the list of all active states in the app the moment an unexpected error occurs.

This is one of the most valuable pieces of context you can ship to a log server or AI webhook: you get not just the stack, but the full picture of what the app was holding when it broke.

ts
import { craftAppConfig, provideTakeAppSnapshot } from '@craft-ng/core';

export const appConfig = craftAppConfig({
  // ...
  providers: [
    provideTakeAppSnapshot((reports) => {
      // reports: SnapshotReport[]
      // — one entry per active state, with its source, ancestry, and current value
      console.warn('App snapshot:', reports);

      // In production you would forward this to a log server or AI webhook:
      // fetch('/api/incident', { method: 'POST', body: JSON.stringify({ reports }) });
    }),
  ],
});

Each SnapshotReport contains:

  • source — the host tag of the state
  • from — the ancestry chain that produced it
  • state — the actual current value

Under the hood, provideTakeAppSnapshot registers its own provideFnWrapper that triggers the snapshot collection whenever an unexpected error bubbles up. You do not need to call it manually.

Craft DOM event hooks

Every DOM event bound from a Craft template goes through the CRAFT_DOM_EVENT_HOOK token. Hooks run in the injector of the component that declared the element, and compose in registration order. A hook must call next() to preserve the component action.

ts
import { craftComponent, button } from '@craft-ng/component';
import { provideCraftDomEventHook } from '@craft-ng/core';

export const SavePanel = craftComponent(
  'SavePanel',
  {
    providers: [
      provideCraftDomEventHook((interaction, next) => {
        console.debug(interaction.interactionName, interaction.event);
        return next();
      }),
    ],
  },
  () => ({}),
  () => button('save', { click: save }, 'Save'),
);

The hook receives the native event, its normalized name, the element, the component name, and a descriptive interactionName such as SavePanel:button:save:click. This is the extension point for analytics, authorization, tracing, or correlation IDs. A hook can also stop an action by not calling next().

provideCorrelationIdTracking

provideCorrelationIdTracking ties every async operation back to the user gesture that triggered it.

When a Craft template action runs, a fresh correlation id is generated from its location (SavePanel:button:save:click:uuid, for example). Navigation back and forward still generate nav-back:uuid and nav-forward:uuid. Every generator invoked downstream — directly or transitively, sync or async — captures that id at invocation time.

ts
import { craftAppConfig, provideCorrelationIdTracking } from '@craft-ng/core';

export const appConfig = craftAppConfig({
  // ...
  providers: [provideCorrelationIdTracking()],
});

Once enabled, the correlation id is attached to the metadata of browser boundaries like Console, so a single yield* Console.error(...) carries:

  • startCorrelationId — the id captured when the current generator was invoked
  • lastCorrelationId — the most recent id observed in the app
  • mayCorrelatedIds — the chain of ids the operation can be linked to

This lets you reconstruct, from logs alone, the full causal chain between "user clicked Save" and "the third sub-request returned 500 four seconds later".

Combined with provideTakeAppSnapshot, you get on every unexpected error:

  • the stack
  • the snapshot of all active states
  • the correlation id chain back to the originating user gesture

Putting It All Together

Wire all three in your appConfig:

ts
import {
  craftAppConfig,
  Console,
  provideFnWrapper,
  provideTakeAppSnapshot,
  provideCorrelationIdTracking,
} from '@craft-ng/core';

export const appConfig = craftAppConfig({
  // ...
  providers: [
    provideFnWrapper(
      'Warning: dependency injection here is not type-safe and may fail at runtime',
      function* (factory, thisArg, args) {
        try {
          return yield* factory.apply(thisArg, args);
        } catch (error) {
          yield* Console.error(error);
          throw error;
        }
      },
    ),
    provideCorrelationIdTracking(),
    provideTakeAppSnapshot((reports) => {
      // forward to your log server or AI webhook
      console.warn('App snapshot:', reports);
    }),
  ],
});

You now have, on any unexpected error: a console error in dev, a full app snapshot, and the correlation chain back to the originating user action — all without a single line of instrumentation inside your business code.

See Also