Skip to content

Commit

Permalink
feat: expose patchActor()
Browse files Browse the repository at this point in the history
`patchActor` is a utility to mutate an existing `ActorRef` allowing addition of an inspector and/or new logger.

Inspectors are additive, so adding a new inspector is not destructive.

The logger needs to be replaced with a new function that calls the original logger and the new logger. If the `ActorRef` is a the root actor, the logger will be set at the system level; otherwise it will only be set for the particular `ActorRef`. This diverges from XState's behavior, which currently intends to only support a system-level logger.

The new `unpatchActor()` is an experimental API available to undo the changes made in `patchActor()`.

fix(logging): fix destructive logging

**xstate-audition** will no longer destroy an Actor's existing logger; it will now compose loggers correctly.
  • Loading branch information
boneskull committed Aug 16, 2024
1 parent 02cd5d2 commit 7871118
Show file tree
Hide file tree
Showing 11 changed files with 251 additions and 62 deletions.
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"tseslint",
"tshy",
"uncurried",
"unpatch",
"xstate"
],
"enabledLanguageIds": [
Expand Down
157 changes: 144 additions & 13 deletions src/actor.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,154 @@
import {type AnyActorRef} from 'xstate';
import {type AnyActorRef, type Subscription} from 'xstate';

import {type AuditionOptions} from './types.js';
import {type AuditionOptions, type LoggerFn} from './types.js';
import {noop} from './util.js';

/**
* Sets up an existing `ActorRef` with a logger and inspector.
* Data stored for each `ActorRef` that has been patched.
*
* @param ref `Actor` or `ActorRef`
* @param options Options for instrumentation
* @returns Instrumented actor
* @internal
*/
export function attachActor(
ref: AnyActorRef,
type PatchData = {inspectorSubscription: Subscription; logger: LoggerFn};

/**
* A `WeakMap` to store the data for each `ActorRef` that has been patched. Used
* by {@link unpatchActor}.
*
* @internal
*/
const patchData = new WeakMap<AnyActorRef, PatchData[]>();

/**
* Utility to _mutate_ an existing {@link ActorRef} allowing addition of an
* inspector and/or new logger.
*
* Inspectors are additive, so adding a new inspector is not destructive.
* Inspectors are applied across the _entire_ system; there is no way to inspect
* only one actor.
*
* The logger needs to be replaced with a new function that calls the original
* logger and the new logger. If the `ActorRef` is a _root_ actor (the one
* created via `createActor()`), the logger will be set at the system level;
* otherwise it will only be set for the particular Actor.
*
* **Warning**: If you use this function and plan using {@link unpatchActor},
* modifying the `ActorRef`'s logger via external means will result in the loss
* of those modifications.
*
* @remarks
* This function is called internally (with its own instrumentation, as
* appropriate) by **xstate-audition** on the root `Actor`, and _for every
* spawned Actor_. That said, it may be useful otherwise to have granular
* control (e.g., when paired with `waitForSpawn()`).
* @example
*
* ```js
* const actor = createActor(someLogic);
* let childActor = await waitForSpawn(actor, 'childId');
* childActor = patchActor(childActor, {
* logger: console.log,
* inspector: console.dir,
* });
* ```
*
* @template Actor The type of `ActorRef` to patch
* @param actor An `ActorRef` (or `Actor`)
* @param options Options
* @returns The original `actor`, but modified
* @see {@link https://stately.ai/docs/inspection}
*/
export function patchActor<Actor extends AnyActorRef>(
actor: Actor,
{inspector: inspect = noop, logger = noop}: AuditionOptions = {},
): void {
ref.system.inspect(inspect);
): Actor {
const subscription = actor.system.inspect(inspect);

const data: PatchData[] = patchData.get(actor) ?? [];

// if there's a reason to prefer a Proxy here, I don't know what it is.

if (actor._parent) {
// @ts-expect-error private
const oldLogger = actor.logger as LoggerFn;

data.push({inspectorSubscription: subscription, logger: oldLogger});

// in this case, ref.logger should be the same as ref._actorScope.logger
// https://github.com/statelyai/xstate/blob/12bde7a3ff47be6bb5a54b03282a77baf76c2bd6/packages/core/src/createActor.ts#L167

// @ts-expect-error private
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
actor.logger = actor._actorScope.logger = (...args: any[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
oldLogger(...args);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
logger(...args);
};
} else {
const oldLogger = actor.system._logger;

data.push({inspectorSubscription: subscription, logger: oldLogger});
// @ts-expect-error private
actor.logger =
actor.system._logger =
// @ts-expect-error private
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
actor._actorScope.logger =
(...args: any[]) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
oldLogger(...args);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
logger(...args);
};
}
patchData.set(actor, data);

return actor;
}

/**
* Reverts what was done in {@link patchActor} for a given `ActorRef`. This will
* revert _all_ changes to a given `ActorRef` made by `patchActor` _in reverse
* order_.
*
* Mutates `actor`.
*
* If `actor` has not been patched via `patchActor`, this function returns the
* identity.
*
* **Warning**: If changes were made to the `ActorRef`'s logger by means _other
* than_ `patchActor` after the first call to `patchActor` (internally or
* otherwise), assume they will be lost.
*
* @remarks
* This function is not currently used internally and is considered
* **experimental**. It may be removed in the future if it does not prove to
* have a reasonable use-case.
* @template Actor The type of the `ActorRef` to unpatch
* @param actor `ActorRef` or `Actor` to unpatch
* @returns `actor`, but unpatched
* @experimental
*/
export function unpatchActor<Actor extends AnyActorRef>(actor: Actor): Actor {
const data = patchData.get(actor);

if (!data) {
return actor;
}

for (const {inspectorSubscription, logger} of data.reverse()) {
inspectorSubscription.unsubscribe();
// @ts-expect-error private
actor.logger = actor._parent
? // @ts-expect-error private
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(actor._actorScope.logger = logger)
: // @ts-expect-error private
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(actor._actorScope.logger = actor.system._logger = logger);
}

patchData.delete(actor);

// @ts-expect-error private
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
ref.logger = ref._actorScope.logger = logger;
return actor;
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './actor.js';

export type * from './types.js';

export * from './until-done.js';
Expand Down
6 changes: 3 additions & 3 deletions src/until-done.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as xs from 'xstate';

import {attachActor} from './actor.js';
import {patchActor} from './actor.js';
import {applyDefaults} from './defaults.js';
import {createAbortablePromiseKit} from './promise-kit.js';
import {startTimer} from './timer.js';
Expand Down Expand Up @@ -160,13 +160,13 @@ const untilDone = <
inspectorObserver.next?.(evt);

if (!seenActors.has(evt.actorRef)) {
attachActor(evt.actorRef, {logger});
patchActor(evt.actorRef, {logger});
seenActors.add(evt.actorRef);
}
},
};

attachActor(actor, {...opts, inspector: doneInspector});
patchActor(actor, {...opts, inspector: doneInspector});
seenActors.add(actor);

// order is important: create promise, then start.
Expand Down
6 changes: 3 additions & 3 deletions src/until-emitted.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as xs from 'xstate';

import {attachActor} from './actor.js';
import {patchActor} from './actor.js';
import {applyDefaults} from './defaults.js';
import {createAbortablePromiseKit} from './promise-kit.js';
import {startTimer} from './timer.js';
Expand Down Expand Up @@ -311,7 +311,7 @@ const untilEmitted = async <
next: (evt) => {
inspectorObserver.next?.(evt);
if (!seenActors.has(evt.actorRef)) {
attachActor(evt.actorRef, {logger});
patchActor(evt.actorRef, {logger});
seenActors.add(evt.actorRef);
}
},
Expand Down Expand Up @@ -352,7 +352,7 @@ const untilEmitted = async <
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const emitted: ActorEmittedTuple<Actor, EmittedTypes> = [] as any;

attachActor(actor, {...opts, inspector: emittedInspector});
patchActor(actor, {...opts, inspector: emittedInspector});

let eventSubscription = subscribe(expectedEventQueue.shift());

Expand Down
6 changes: 3 additions & 3 deletions src/until-event-received.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as xs from 'xstate';

import {attachActor} from './actor.js';
import {patchActor} from './actor.js';
import {applyDefaults} from './defaults.js';
import {createAbortablePromiseKit} from './promise-kit.js';
import {startTimer} from './timer.js';
Expand Down Expand Up @@ -382,7 +382,7 @@ const untilEventReceived = async <
inspectorObserver.next?.(evt);

if (!seenActors.has(evt.actorRef)) {
attachActor(evt.actorRef, {logger});
patchActor(evt.actorRef, {logger});
seenActors.add(evt.actorRef);
}

Expand Down Expand Up @@ -413,7 +413,7 @@ const untilEventReceived = async <
},
};

attachActor(actor, {...opts, inspector: eventReceivedInspector});
patchActor(actor, {...opts, inspector: eventReceivedInspector});
seenActors.add(actor);

const expectedEventQueue = [...eventTypes];
Expand Down
6 changes: 3 additions & 3 deletions src/until-event-sent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as xs from 'xstate';

import {attachActor} from './actor.js';
import {patchActor} from './actor.js';
import {applyDefaults} from './defaults.js';
import {createAbortablePromiseKit} from './promise-kit.js';
import {startTimer} from './timer.js';
Expand Down Expand Up @@ -376,7 +376,7 @@ const untilEventSent = async <
inspectorObserver.next?.(evt);

if (!seenActors.has(evt.actorRef)) {
attachActor(evt.actorRef, {logger});
patchActor(evt.actorRef, {logger});
seenActors.add(evt.actorRef);
}

Expand Down Expand Up @@ -404,7 +404,7 @@ const untilEventSent = async <
},
};

attachActor(actor, {...opts, inspector: eventSentInspector});
patchActor(actor, {...opts, inspector: eventSentInspector});
seenActors.add(actor);

const expectedEventQueue = [...eventTypes];
Expand Down
6 changes: 3 additions & 3 deletions src/until-snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as xs from 'xstate';

import {attachActor} from './actor.js';
import {patchActor} from './actor.js';
import {applyDefaults} from './defaults.js';
import {
type AnySnapshotEmitterActor,
Expand Down Expand Up @@ -189,13 +189,13 @@ const untilSnapshot = <Actor extends AnySnapshotEmitterActor>(
next: (evt) => {
inspectorObserver.next?.(evt);
if (!seenActors.has(evt.actorRef)) {
attachActor(evt.actorRef, {logger});
patchActor(evt.actorRef, {logger});
seenActors.add(evt.actorRef);
}
},
};

attachActor(actor, {...opts, inspector: snapshotInspector});
patchActor(actor, {...opts, inspector: snapshotInspector});
seenActors.add(actor);

const promise = xs
Expand Down
6 changes: 3 additions & 3 deletions src/until-spawn.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as xs from 'xstate';

import {attachActor} from './actor.js';
import {patchActor} from './actor.js';
import {applyDefaults} from './defaults.js';
import {createAbortablePromiseKit} from './promise-kit.js';
import {startTimer} from './timer.js';
Expand Down Expand Up @@ -255,7 +255,7 @@ const untilSpawn = async <Logic extends xs.AnyActorLogic>(
inspectorObserver.next?.(evt);

if (!seenActors.has(evt.actorRef)) {
attachActor(evt.actorRef, {logger});
patchActor(evt.actorRef, {logger});
seenActors.add(evt.actorRef);
}

Expand All @@ -270,7 +270,7 @@ const untilSpawn = async <Logic extends xs.AnyActorLogic>(
},
};

attachActor(actor, {...opts, inspector: spawnInspector});
patchActor(actor, {...opts, inspector: spawnInspector});
seenActors.add(actor);
startTimer(
actor,
Expand Down
6 changes: 3 additions & 3 deletions src/until-transition.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as xs from 'xstate';

import {attachActor} from './actor.js';
import {patchActor} from './actor.js';
import {applyDefaults} from './defaults.js';
import {createAbortablePromiseKit} from './promise-kit.js';
import {startTimer} from './timer.js';
Expand Down Expand Up @@ -382,7 +382,7 @@ const untilTransition = <Actor extends AnyStateMachineActor>(
inspectorObserver.next?.(evt);

if (!seenActors.has(evt.actorRef)) {
attachActor(evt.actorRef, {logger});
patchActor(evt.actorRef, {logger});
seenActors.add(evt.actorRef);
}

Expand All @@ -403,7 +403,7 @@ const untilTransition = <Actor extends AnyStateMachineActor>(
},
};

attachActor(actor, {
patchActor(actor, {
...opts,
inspector: transitionInspector,
});
Expand Down
Loading

0 comments on commit 7871118

Please sign in to comment.