diff --git a/beta/docs/.vitepress/sidebars.ts b/beta/docs/.vitepress/sidebars.ts index 104a09e23..a95e9a00d 100644 --- a/beta/docs/.vitepress/sidebars.ts +++ b/beta/docs/.vitepress/sidebars.ts @@ -312,6 +312,10 @@ const commonSidebars: LSidebar = { text: {en: 'launch', ru: 'launch'}, link: '/api/effector/launch', }, + { + text: {en: 'inspect', ru: 'inspect'}, + link: '/api/effector/inspect', + }, ], }, { diff --git a/beta/docs/api/effector/inspect.md b/beta/docs/api/effector/inspect.md new file mode 100644 index 000000000..1072455ea --- /dev/null +++ b/beta/docs/api/effector/inspect.md @@ -0,0 +1,179 @@ +--- +title: inspect +lang: en-US +--- + +# Inspect API + +Special API methods designed to handle debugging and monitoring use cases without giving too much access to internals of your's actual app. + +Useful to create developer tools and production monitoring and observability instruments. + +## Inspect + +Allows to track any computations, that have happened in the effector's kernel. + +```ts +import {inspect, type Message} from 'effector/inspect' + +import {someEvent} from './app-code' + +function logInspectMessage(m: Message) { + const {name, value, kind} = m + + return console.log(`[${kind}] ${name} ${value}`) +} + +inspect({ + fn: m => { + logInspectMessage(m) + }, +}) + +someEvent(42) +// will log something like +// [event] someEvent 42 +// [on] 42 +// [store] $count 1337 +// ☝️ let's say that reducer adds 1295 to provided number +// +// and so on, any triggers +``` + +Computations tracking is restricted by [Scope](./Scope.md). +If no scope is provided - default out-of-scope mode computations will be tracked. + +```ts +import {fork, allSettled} from 'effector' +import {inspect, type Message} from 'effector/inspect' + +import {someEvent} from './app-code' + +function logInspectMessage(m: Message) { + const {name, value, kind} = m + + return console.log(`[${kind}] ${name} ${value}`) +} + +const myScope = fork() + +inspect({ + scope: myScope, + fn: m => { + logInspectMessage(m) + }, +}) + +someEvent(42) +// ☝️ No logs! That's because tracking was restricted by myScope + +allSettled(someEvent, {scope: myScope, params: 42}) +// [event] someEvent 42 +// [on] 42 +// [store] $count 1337 +``` + +### Tracing + +Adding `trace: true` setting allows to look up previous computations, that led to this specific one. +It is useful to debug the specific reason of some event happening + +```ts +import {fork, allSettled} from 'effector' +import {inspect, type Message} from 'effector/inspect' + +import {someEvent, $count} from './app-code' + +function logInspectMessage(m: Message) { + const {name, value, kind} = m + + return console.log(`[${kind}] ${name} ${value}`) +} + +const myScope = fork() + +inspect({ + scope: myScope, + trace: true, // <- explicit setting is needed + fn: m => { + if (m.kind === 'store' && m.sid === $count.sid) { + m.trace.forEach(tracedMessage => { + logInspectMessage(tracedMessage) + // ☝️ here we are loggin the trace of specific store update + }) + } + }, +}) + +allSettled(someEvent, {scope: myScope, params: 42}) +// [on] 42 +// [event] someEvent 42 +// ☝️ traces are provided in backwards order, because we are looking back in time +``` + +### Errors + +Effector does not allow exceptions in pure functions. In such case branch computation is stopped and exception is logged. + +There is also a special message type in such case: + +```ts +inspect({ + fn: m => { + if (m.type === 'error') { + // do something about it + console.log(`${m.kind} ${m.name} computation has failed with ${m.error}`) + } + }, +}) +``` + +## Inspect Graph + +Allows to track declarations of units, [factories](./babel-plugin.md#factories) and [regions](./withRegion.md) + +```ts +import {createStore} from "effector" +import { inspectGraph, type Declaration } from "effector/inspect" + +function printDeclaration(d: Declaration) { + console.log(`${d.kind} ${d.name}`) +} + +inspectGraph({ + fn: (d) => { + printDeclaration(d) + } +}) + +const $count = createStore(0) +// logs "store $count" to console +``` + +## withRegion + +Meta-data provided via region's root node is available on declaration. + +```ts +import {createNode, withRegion, createStore} from "effector" +import { inspectGraph, type Declaration } from "effector/inspect" + +function createCustomSomething(config) { + const $something = createStore(0) + + withRegion(createNode({meta: {hello: 'world'}}), () => { + // some code + }) + + return $something +} +inspectGraph({ + fn: (d) => { + if (d.type === "region") + console.log(d.meta.hello) + } +}) + +const $some = createCustomSomething({}) +// logs "world" +``` diff --git a/docs/api/effector/index.md b/docs/api/effector/index.md index 99c7b3ebc..83b8824b6 100644 --- a/docs/api/effector/index.md +++ b/docs/api/effector/index.md @@ -47,3 +47,4 @@ title: API Reference - [clearNode](./clearNode.md) - [withRegion](./withRegion.md) +- [Inspect API](./inspectApi.md) diff --git a/docs/api/effector/inspect.md b/docs/api/effector/inspect.md new file mode 100644 index 000000000..ddcc71853 --- /dev/null +++ b/docs/api/effector/inspect.md @@ -0,0 +1,179 @@ +--- +id: inspect +title: inspect +--- + +# Inspect API + +Special API methods designed to handle debugging and monitoring use cases without giving too much access to internals of your's actual app. + +Useful to create developer tools and production monitoring and observability instruments. + +## Inspect + +Allows to track any computations, that have happened in the effector's kernel. + +```ts +import {inspect, type Message} from 'effector/inspect' + +import {someEvent} from './app-code' + +function logInspectMessage(m: Message) { + const {name, value, kind} = m + + return console.log(`[${kind}] ${name} ${value}`) +} + +inspect({ + fn: m => { + logInspectMessage(m) + }, +}) + +someEvent(42) +// will log something like +// [event] someEvent 42 +// [on] 42 +// [store] $count 1337 +// ☝️ let's say that reducer adds 1295 to provided number +// +// and so on, any triggers +``` + +Computations tracking is restricted by [Scope](./Scope.md). +If no scope is provided - default out-of-scope mode computations will be tracked. + +```ts +import {fork, allSettled} from 'effector' +import {inspect, type Message} from 'effector/inspect' + +import {someEvent} from './app-code' + +function logInspectMessage(m: Message) { + const {name, value, kind} = m + + return console.log(`[${kind}] ${name} ${value}`) +} + +const myScope = fork() + +inspect({ + scope: myScope, + fn: m => { + logInspectMessage(m) + }, +}) + +someEvent(42) +// ☝️ No logs! That's because tracking was restricted by myScope + +allSettled(someEvent, {scope: myScope, params: 42}) +// [event] someEvent 42 +// [on] 42 +// [store] $count 1337 +``` + +### Tracing + +Adding `trace: true` setting allows to look up previous computations, that led to this specific one. +It is useful to debug the specific reason of some event happening + +```ts +import {fork, allSettled} from 'effector' +import {inspect, type Message} from 'effector/inspect' + +import {someEvent, $count} from './app-code' + +function logInspectMessage(m: Message) { + const {name, value, kind} = m + + return console.log(`[${kind}] ${name} ${value}`) +} + +const myScope = fork() + +inspect({ + scope: myScope, + trace: true, // <- explicit setting is needed + fn: m => { + if (m.kind === 'store' && m.sid === $count.sid) { + m.trace.forEach(tracedMessage => { + logInspectMessage(tracedMessage) + // ☝️ here we are loggin the trace of specific store update + }) + } + }, +}) + +allSettled(someEvent, {scope: myScope, params: 42}) +// [on] 42 +// [event] someEvent 42 +// ☝️ traces are provided in backwards order, because we are looking back in time +``` + +### Errors + +Effector does not allow exceptions in pure functions. In such case branch computation is stopped and exception is logged. + +There is also a special message type in such case: + +```ts +inspect({ + fn: m => { + if (m.type === 'error') { + // do something about it + console.log(`${m.kind} ${m.name} computation has failed with ${m.error}`) + } + }, +}) +``` + +## Inspect Graph + +Allows to track declarations of units, [factories](./babel-plugin.md#factories) and [regions](./withRegion.md) + +```ts +import {createStore} from "effector" +import { inspectGraph, type Declaration } from "effector/inspect" + +function printDeclaration(d: Declaration) { + console.log(`${d.kind} ${d.name}`) +} + +inspectGraph({ + fn: (d) => { + printDeclaration(d) + } +}) + +const $count = createStore(0) +// logs "store $count" to console +``` + +## withRegion + +Meta-data provided via region's root node is available on declaration. + +```ts +import {createNode, withRegion, createStore} from "effector" +import { inspectGraph, type Declaration } from "effector/inspect" + +function createCustomSomething(config) { + const $something = createStore(0) + + withRegion(createNode({meta: {hello: 'world'}}), () => { + // some code + }) + + return $something +} +inspectGraph({ + fn: (d) => { + if (d.type === "region") + console.log(d.meta.hello) + } +}) + +const $some = createCustomSomething({}) +// logs "world" +``` diff --git a/packages/effector/inspect.d.ts b/packages/effector/inspect.d.ts new file mode 100644 index 000000000..efd4f5861 --- /dev/null +++ b/packages/effector/inspect.d.ts @@ -0,0 +1,104 @@ +import {Scope, Subscription, Show} from 'effector' + +export type Message = { + type: 'update' | 'error' + error?: unknown + value: unknown + stack: Record + kind: string + sid?: string + id: string + name?: string + loc?: { + file: string + line: number + column: number + } + meta: Record + trace?: Message[] +} + +export function inspect(config: { + scope?: Scope + trace?: boolean + fn: (message: Message) => void +}): Subscription + +type Region = + | { + type: 'region' + meta: Record + region?: Region + } + | { + type: 'factory' + region?: Region + meta: { + sid?: string + name?: string + method?: string + loc?: { + file: string + line: number + column: number + } + } + } + +export type Declaration = + | { + type: 'unit' + kind: string + name?: string + id: string + sid?: string + loc?: { + file: string + line: number + column: number + } + meta: Record + region?: Show + // for derived units - stores or events + derived?: boolean + } + | { + type: 'factory' + meta: Record + region?: Region + sid?: string + name?: string + method?: string + loc?: { + file: string + line: number + column: number + } + // these fields are not provided to factories + // however, to make it easier to work with it in Typescript + // and to avoid annoying `some prop does not exist` errors + // they are explictily set to undefined + kind?: undefined + id?: undefined + derived?: undefined + } + | { + type: 'region' + region?: Region + meta: Record + // these fields are not provided to regions + // however, to make it easier to work with it in Typescript + // and to avoid annoying `some prop does not exist` errors + // they are explictily set to undefined + kind?: undefined + id?: undefined + derived?: undefined + sid?: undefined + name?: undefined + method?: undefined + loc?: undefined + } + +export function inspectGraph(config: { + fn: (declaration: Declaration) => void +}): Subscription diff --git a/packages/effector/inspect.ts b/packages/effector/inspect.ts new file mode 100644 index 000000000..e1d200d81 --- /dev/null +++ b/packages/effector/inspect.ts @@ -0,0 +1 @@ +export {inspect, inspectGraph} from '../../src/effector/inspect' diff --git a/src/effector/__tests__/inspect.test.ts b/src/effector/__tests__/inspect.test.ts new file mode 100644 index 000000000..a8d03910e --- /dev/null +++ b/src/effector/__tests__/inspect.test.ts @@ -0,0 +1,726 @@ +import {inspect, Message, inspectGraph, Declaration} from 'effector/inspect' +import { + createEvent, + createStore, + sample, + fork, + allSettled, + createEffect, + combine, + withRegion, + createNode, +} from 'effector' +import {argumentHistory} from 'effector/fixtures' +import {performance} from 'perf_hooks' +import {withFactory} from '../region' + +function compactMessage(m: Message) { + return `${m.type} of '${m.name}' [${m.kind}] to value of '${ + m.value + }' (id:${typeof m.id}, sid:${typeof m.sid}, loc:${typeof m.loc}, meta:${typeof m.meta})` +} + +describe('inspect API', () => { + test('should be possible to track chain of events', () => { + const start = createEvent() + const $a = createStore(0).on(start, s => s + 1) + const $b = $a.map(s => s + 1) + + const end = createEvent() + + sample({ + source: [$a, $b], + clock: start, + fn: ([a, b]) => a + b, + target: end, + }) + + const trackMock = jest.fn() + inspect({ + fn: m => trackMock(compactMessage(m)), + }) + + start() + + expect(argumentHistory(trackMock).length).toBeGreaterThan(0) + expect(argumentHistory(trackMock)).toMatchInlineSnapshot(` + Array [ + "update of 'start' [event] to value of 'undefined' (id:string, sid:string, loc:object, meta:object)", + "update of 'undefined' [on] to value of '1' (id:string, sid:undefined, loc:undefined, meta:object)", + "update of '$a' [store] to value of '1' (id:string, sid:string, loc:object, meta:object)", + "update of 'updates' [event] to value of '1' (id:string, sid:object, loc:undefined, meta:object)", + "update of 'undefined' [map] to value of '2' (id:string, sid:undefined, loc:undefined, meta:object)", + "update of '$a → *' [store] to value of '2' (id:string, sid:object, loc:undefined, meta:object)", + "update of 'updates' [event] to value of '2' (id:string, sid:object, loc:undefined, meta:object)", + "update of 'undefined' [combine] to value of '1,2' (id:string, sid:undefined, loc:undefined, meta:object)", + "update of 'combine($a, $a → *)' [store] to value of '1,2' (id:string, sid:object, loc:undefined, meta:object)", + "update of 'updates' [event] to value of '1,2' (id:string, sid:object, loc:undefined, meta:object)", + "update of 'undefined' [sample] to value of '3' (id:string, sid:string, loc:object, meta:object)", + "update of 'end' [event] to value of '3' (id:string, sid:string, loc:object, meta:object)", + ] + `) + }) + + test('should be possible to track chain of events in specific scope', () => { + const start = createEvent() + const $a = createStore(0).on(start, s => s + 1) + const $b = $a.map(s => s + 1) + + const end = createEvent() + + sample({ + source: [$a, $b], + clock: start, + fn: ([a, b]) => a + b, + target: end, + }) + + const scopeToTrack = fork() + const anotherScope = fork() + + const trackMock = jest.fn() + inspect({ + scope: scopeToTrack, + fn: m => trackMock(compactMessage(m)), + }) + + start('SHOULD_NOT_BE_TRACKED') + allSettled(start, {scope: scopeToTrack, params: 'MUST_BE_TRACKED'}) + allSettled(start, {scope: anotherScope, params: 'SHOULD_NOT_BE_TRACKED'}) + + expect(argumentHistory(trackMock).length).toBeGreaterThan(0) + // We explicitly said, which scope computes we want to track + expect( + argumentHistory(trackMock).join(',').includes('SHOULD_NOT_BE_TRACKED'), + ).toBe(false) + expect( + argumentHistory(trackMock).join(',').includes('MUST_BE_TRACKED'), + ).toBe(true) + expect(argumentHistory(trackMock)).toMatchInlineSnapshot(` + Array [ + "update of 'start' [event] to value of 'MUST_BE_TRACKED' (id:string, sid:string, loc:object, meta:object)", + "update of 'undefined' [on] to value of '2' (id:string, sid:undefined, loc:undefined, meta:object)", + "update of '$a' [store] to value of '2' (id:string, sid:string, loc:object, meta:object)", + "update of 'updates' [event] to value of '2' (id:string, sid:object, loc:undefined, meta:object)", + "update of 'undefined' [undefined] to value of 'undefined' (id:string, sid:undefined, loc:undefined, meta:object)", + "update of 'undefined' [map] to value of '3' (id:string, sid:undefined, loc:undefined, meta:object)", + "update of '$a → *' [store] to value of '3' (id:string, sid:object, loc:undefined, meta:object)", + "update of 'updates' [event] to value of '3' (id:string, sid:object, loc:undefined, meta:object)", + "update of 'undefined' [combine] to value of '2,3' (id:string, sid:undefined, loc:undefined, meta:object)", + "update of 'combine($a, $a → *)' [store] to value of '2,3' (id:string, sid:object, loc:undefined, meta:object)", + "update of 'updates' [event] to value of '2,3' (id:string, sid:object, loc:undefined, meta:object)", + "update of 'undefined' [sample] to value of '5' (id:string, sid:string, loc:object, meta:object)", + "update of 'end' [event] to value of '5' (id:string, sid:string, loc:object, meta:object)", + "update of 'undefined' [undefined] to value of 'undefined' (id:string, sid:undefined, loc:undefined, meta:object)", + ] + `) + }) + + test('unsub should work', () => { + const up = createEvent() + const $count = createStore(0).on(up, s => s + 1) + + const trackMock = jest.fn() + const unsub = inspect({ + fn: m => trackMock(compactMessage(m)), + }) + + up() + up() + const currentHistory = [...argumentHistory(trackMock)] + expect(currentHistory).toMatchInlineSnapshot(` + Array [ + "update of 'up' [event] to value of 'undefined' (id:string, sid:string, loc:object, meta:object)", + "update of 'undefined' [on] to value of '1' (id:string, sid:undefined, loc:undefined, meta:object)", + "update of '$count' [store] to value of '1' (id:string, sid:string, loc:object, meta:object)", + "update of 'updates' [event] to value of '1' (id:string, sid:object, loc:undefined, meta:object)", + "update of 'up' [event] to value of 'undefined' (id:string, sid:string, loc:object, meta:object)", + "update of 'undefined' [on] to value of '2' (id:string, sid:undefined, loc:undefined, meta:object)", + "update of '$count' [store] to value of '2' (id:string, sid:string, loc:object, meta:object)", + "update of 'updates' [event] to value of '2' (id:string, sid:object, loc:undefined, meta:object)", + ] + `) + + unsub() + + up() + + const nextHistory = [...argumentHistory(trackMock)] + expect(nextHistory).toEqual(currentHistory) + expect($count.getState()).toEqual(3) + }) + + test('trace should work', () => { + const start = createEvent() + const $a = createStore(0).on(start, s => s + 1) + const $b = $a.map(s => s + 1) + + const end = createEvent() + + sample({ + source: [$a, $b], + clock: start, + fn: ([a, b]) => a + b, + target: end, + }) + + const trackMock = jest.fn() + inspect({ + trace: true, + fn: m => { + if (m.sid === end.sid) { + trackMock(compactMessage(m)) + m.trace!.forEach(trace => { + trackMock(`<- ${compactMessage(trace)}`) + }) + } + }, + }) + + start() + + expect(argumentHistory(trackMock).length).toBeGreaterThan(0) + expect(argumentHistory(trackMock)).toMatchInlineSnapshot(` + Array [ + "update of 'end' [event] to value of '3' (id:string, sid:string, loc:object, meta:object)", + "<- update of 'undefined' [sample] to value of '3' (id:string, sid:string, loc:object, meta:object)", + "<- update of 'start' [event] to value of 'undefined' (id:string, sid:string, loc:object, meta:object)", + ] + `) + }) +}) + +function compactDeclaration(d: Declaration) { + if (d.type === 'region') return `region, parent: ${typeof d.region}` + if (d.type === 'factory') + return `factory, ${d.method}, ${d.sid}, ${ + d.name + }, parent: ${typeof d.region}` + + return `${d.derived ? 'derived ' : ''}${d.type} ${d.name} (${ + d.kind + }) created (sid ${typeof d.sid}, parent region: ${typeof d.region}, id: ${typeof d.id}, loc: ${typeof d.loc})` +} + +describe('inspectGraph API', () => { + test('should work', () => { + const declMock = jest.fn() + const unsub = inspectGraph({ + fn: d => declMock(compactDeclaration(d)), + }) + + const event1 = createEvent() + const $store2 = createStore(0) + const effectFx = createEffect(() => {}) + const $store3 = $store2.map(x => x) + const event4 = event1.map(x => x) + const event5 = event1.prepend(x => x) + const $store6 = combine([$store2, $store3]) + + expect(argumentHistory(declMock).length).toBeGreaterThan(0) + const history = [...argumentHistory(declMock)] + expect(history).toMatchInlineSnapshot(` + Array [ + "unit event1 (event) created (sid string, parent region: undefined, id: string, loc: object)", + "derived unit updates (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "unit reinit (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "unit $store2 (store) created (sid string, parent region: undefined, id: string, loc: object)", + "unit effectFx (effect) created (sid string, parent region: undefined, id: string, loc: object)", + "derived unit finally (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit done (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit fail (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit doneData (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit failData (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit updates (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "unit reinit (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "unit 43 (store) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit updates (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit inFlight (store) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit updates (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit pending (store) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit updates (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit $store2 → * (store) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit event1 → * (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "unit * → event1 (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit updates (event) created (sid object, parent region: undefined, id: string, loc: undefined)", + "derived unit $store6 (store) created (sid string, parent region: undefined, id: string, loc: object)", + ] + `) + + unsub() + + const event2 = createEvent() + const $store1 = createStore(0) + const effFx = createEffect(() => {}) + + expect(argumentHistory(declMock)).toEqual(history) + }) + describe('region support', () => { + test('one-level withRegion', () => { + function customOperator(config: Record) { + withRegion( + createNode({ + meta: { + myLibType: 'customOperator', + myLibConfig: config, + }, + }), + () => { + const internalEvent = createEvent() + }, + ) + } + + const declared = jest.fn() + const nonRegionalUnitDeclared = jest.fn() + const regionalUnitDeclared = jest.fn() + inspectGraph({ + fn: d => { + declared(`${d.type} ${d.name} created`) + if (!d.region) { + nonRegionalUnitDeclared() + } else { + regionalUnitDeclared(d.region.meta) + } + }, + }) + + const $source = createStore(0) + const targetEvent = createEvent() + + customOperator({ + source: $source, + target: targetEvent, + }) + + expect(regionalUnitDeclared).toHaveBeenCalledTimes(1) + expect(regionalUnitDeclared).toHaveBeenCalledWith({ + myLibType: 'customOperator', + myLibConfig: { + source: $source, + target: targetEvent, + }, + }) + expect(argumentHistory(declared)).toMatchInlineSnapshot(` + Array [ + "unit updates created", + "unit reinit created", + "unit $source created", + "unit targetEvent created", + "unit internalEvent created", + "region undefined created", + ] + `) + }) + test('one-level withFactory', () => { + function customOperator(config: Record) { + const internalEvent = createEvent() + } + + const declared = jest.fn() + const nonRegionalUnitDeclared = jest.fn() + const regionalUnitDeclared = jest.fn() + inspectGraph({ + fn: d => { + declared(`${d.type} ${d.name} created`) + if (!d.region) { + nonRegionalUnitDeclared() + } else { + regionalUnitDeclared(d.region.meta) + } + }, + }) + + const $source = createStore(0) + const targetEvent = createEvent() + + withFactory({ + sid: 'customOperator-call-1', + method: 'customOperator', + name: 'test-name', + fn: () => + customOperator({ + source: $source, + target: targetEvent, + }), + }) + expect(regionalUnitDeclared).toHaveBeenCalledTimes(1) + expect(regionalUnitDeclared).toHaveBeenCalledWith( + expect.objectContaining({ + sid: 'customOperator-call-1', + method: 'customOperator', + }), + ) + expect(argumentHistory(declared)).toMatchInlineSnapshot(` + Array [ + "unit updates created", + "unit reinit created", + "unit $source created", + "unit targetEvent created", + "unit internalEvent created", + "factory test-name created", + ] + `) + }) + test('nested regions', () => { + function customOperator(config: Record) { + withRegion(createNode({meta: {region: 'outer'}}), () => { + withRegion(createNode({meta: {region: 'inner'}}), () => { + const internalEvent = createEvent() + }) + }) + } + + const declared = jest.fn() + const nonRegionalUnitDeclared = jest.fn() + const regionalUnitDeclared = jest.fn() + inspectGraph({ + fn: d => { + declared(`${d.type} ${d.name} created`) + if (d.type === 'unit') { + if (!d.region) { + nonRegionalUnitDeclared() + } else { + regionalUnitDeclared(d.region) + } + } + }, + }) + + const $source = createStore(0) + const targetEvent = createEvent() + + withFactory({ + sid: 'customOperator-call-1', + method: 'customOperator', + name: 'test-name', + fn: () => + customOperator({ + source: $source, + target: targetEvent, + }), + }) + + expect(regionalUnitDeclared).toHaveBeenCalledTimes(1) + expect(regionalUnitDeclared).toHaveBeenCalledWith({ + type: 'region', + meta: { + region: 'inner', + }, + region: { + type: 'region', + meta: {region: 'outer'}, + region: { + type: 'factory', + region: undefined, + sid: 'customOperator-call-1', + method: 'customOperator', + name: 'test-name', + meta: expect.objectContaining({ + sid: 'customOperator-call-1', + method: 'customOperator', + }), + }, + }, + }) + expect(argumentHistory(declared)).toMatchInlineSnapshot(` + Array [ + "unit updates created", + "unit reinit created", + "unit $source created", + "unit targetEvent created", + "unit internalEvent created", + "region undefined created", + "region undefined created", + "factory test-name created", + ] + `) + }) + }) +}) + +describe('real use cases', () => { + test('measure effect timings', async () => { + const start = createEvent() + + const fx1 = createEffect(() => new Promise(r => setTimeout(r, 12))) + const fx2 = createEffect(() => new Promise(r => setTimeout(r, 22))) + const fx3 = createEffect(() => new Promise(r => setTimeout(r, 32))) + + sample({ + clock: start, + target: [fx1, fx2, fx3], + }) + + const scope = fork() + + const times: Record = {} + const startRecord = (name: string) => { + const start = Date.now() + + return () => { + times[name] = Date.now() - start + } + } + + const timers = new Map void>() + + const unsub = inspect({ + scope, + fn: m => { + if (m.kind === 'effect') { + timers.set(m.stack.fxID as string, startRecord(m.name!)) + } + if (m.kind === 'event' && m.meta.named === 'finally') { + const stop = timers.get(m.stack.fxID as string) + stop?.() + } + }, + }) + + await allSettled(start, {scope}) + + const floor = (n: number) => Math.floor(n / 10) * 10 + + expect(floor(times.fx1)).toEqual(10) + expect(floor(times.fx2)).toEqual(20) + expect(floor(times.fx3)).toEqual(30) + + unsub() + }) + test('monitor out-of-scope computations', async () => { + const start = createEvent() + + const scope = fork() + + const outOfScope = jest.fn() + inspect({ + fn: () => outOfScope(), + }) + + allSettled(start, {scope}) + + expect(outOfScope).not.toBeCalled() + + start() + + expect(outOfScope).toBeCalled() + }) + test('monitor sid-less stores', async () => { + const missingSid = jest.fn() + + inspectGraph({ + fn: d => { + if (d.kind === 'store' && !d.sid) missingSid(d.name) + }, + }) + + const $a = createStore(null) + const $b = createStore(null, {sid: null as unknown as string}) + const $c = createStore(null) + + expect(missingSid).toBeCalledTimes(1) + expect(missingSid).toBeCalledWith('$b') + }) + test('monitor stores with duplicated sid`s', async () => { + const duplicatedSid = jest.fn() + + const sidMap: Record = {} + + inspectGraph({ + fn: d => { + if (d.kind === 'store' && d.sid) { + if (sidMap[d.sid]) { + duplicatedSid(d.name) + } else { + sidMap[d.sid] = true + } + } + }, + }) + + const $a = createStore(null, {sid: '$a'}) + const $b = createStore(null) + const $c = createStore(null, {sid: '$a'}) + + expect(duplicatedSid).toBeCalledTimes(1) + }) + test('profile computations', async () => { + const start = createEvent() + + const end = sample({ + clock: start, + fn: () => { + let c = 0 + while (c < 10_000) { + c++ + } + }, + }) + + const timeLog = jest.fn() + let time = 0 + let tracking = false + + const scope = fork() + + inspect({ + scope, + fn: m => { + if (!tracking) { + tracking = true + time = performance.now() + queueMicrotask(() => { + tracking = false + timeLog({starter: m.name, ms: performance.now() - time}) + }) + } + }, + }) + + await allSettled(start, {scope}) + + expect(tracking).toBe(false) + expect(timeLog).toBeCalledTimes(1) + expect(timeLog).toBeCalledWith({starter: 'start', ms: expect.any(Number)}) + expect(argumentHistory(timeLog)[0].ms).toBeGreaterThan(0) + }) + test('list units by file', () => { + const unitsByFile: Record = {} + inspectGraph({ + fn: d => { + if (d.loc) { + const file = d.loc.file.split('/').at(-1) || '' + const units = unitsByFile[file] || [] + units.push(d.name!) + unitsByFile[file] = units + } + }, + }) + const $a = createStore(null) + const $b = createStore(null) + const $c = createStore(null) + + expect(unitsByFile).toEqual({ + 'inspect.test.ts': ['$a', '$b', '$c'], + }) + }) + test('track pure function errors in custom way', () => { + // something that logs errors directly into our monitoring systems + // providing additional context + const appLogger = { + log: jest.fn(message => ({ + message, + logContext: { + appName: 'my-app', + appVersion: '1.0.0', + }, + })), + } + + const start = createEvent() + const started = sample({ + clock: start, + fn: () => { + throw new Error('unexpected error, branch computation stopped') + }, + }) + + const scope = fork() + + inspect({ + scope, + fn: m => { + if (m.type === 'error') { + appLogger.log(`name: ${m.name}, error: ${(m.error as Error).message}`) + } + }, + }) + + allSettled(start, {scope}) + + expect(argumentHistory(appLogger.log)[0]).toEqual( + 'name: started, error: unexpected error, branch computation stopped', + ) + }) + test('list both units and custom stuff by file', () => { + function createQuery(config: Record) { + return withRegion( + createNode({ + meta: { + config, + }, + }), + () => { + const start = createEvent() + const $data = createStore(0) + + return { + start, + $data, + } + }, + ) + } + + const unitsByFile: Record< + string, + { + name: string + value: unknown + }[] + > = {} + inspectGraph({ + fn: d => { + if (d.loc) { + const file = d.loc.file.split('/').at(-1) || '' + const units = unitsByFile[file] || [] + const name = d.region + ? `${d.region.region?.meta.name!}/${d.name!}` + : d.name! + + units.push({ + name: name, + value: d.meta.defaultState, + }) + unitsByFile[file] = units + } + }, + }) + const $a = createStore(0) + const $b = createStore('1') + const $c = createStore(2) + + const myQuery = withFactory({ + sid: 'some-sid', + name: 'myQuery', + method: 'createQuery', + fn: () => createQuery({a: 1, b: 2}), + }) + + expect(unitsByFile).toEqual({ + 'inspect.test.ts': [ + { + name: '$a', + value: 0, + }, + { + name: '$b', + value: '1', + }, + { + name: '$c', + value: 2, + }, + { + name: 'myQuery/start', + value: undefined, + }, + { + name: 'myQuery/$data', + value: 0, + }, + ], + }) + }) +}) diff --git a/src/effector/createEffect.ts b/src/effector/createEffect.ts index 585c87072..2efa7b302 100644 --- a/src/effector/createEffect.ts +++ b/src/effector/createEffect.ts @@ -26,7 +26,7 @@ type RunnerData = { export function createEffect( nameOrConfig: any, - maybeConfig?: any, + maybeConfig: any = {}, ): Effect { const config = flattenConfig( isFunction(nameOrConfig) ? {handler: nameOrConfig} : nameOrConfig, @@ -34,7 +34,7 @@ export function createEffect( ) const instance = createEvent( isFunction(nameOrConfig) ? {handler: nameOrConfig} : nameOrConfig, - maybeConfig, + {...maybeConfig, actualOp: EFFECT}, ) as unknown as Effect const node = getGraph(instance) setMeta(node, 'op', (instance.kind = EFFECT)) diff --git a/src/effector/createUnit.ts b/src/effector/createUnit.ts index f6dd03835..05d038d53 100644 --- a/src/effector/createUnit.ts +++ b/src/effector/createUnit.ts @@ -31,7 +31,7 @@ import {createName} from './naming' import {createLinkNode} from './forward' import {watchUnit} from './watch' import {createSubscription} from './subscription' -import {readTemplate, readSidRoot} from './region' +import {readTemplate, readSidRoot, reportDeclaration} from './region' import { getSubscribers, getStoreState, @@ -159,7 +159,7 @@ export function createEvent( const template = readTemplate() const finalEvent = Object.assign(event, { graphite: createNode({ - meta: initUnit(EVENT, event, config), + meta: initUnit(config.actualOp || EVENT, event, config), regional: true, }), create(params: Payload, _: any[]) { @@ -191,6 +191,7 @@ export function createEvent( if (config?.domain) { config.domain.hooks.event(finalEvent) } + reportDeclaration(finalEvent.graphite) return finalEvent } function on( @@ -337,7 +338,10 @@ export function createStore( mov({from: STACK, target: plainState}), ], child: updates, - meta, + meta: { + ...meta, + defaultState, + }, regional: true, }) const serializeMeta = getMeta(store, 'serialize') @@ -369,10 +373,14 @@ export function createStore( } if (!derived) { - store.reinit = createEvent() + store.reinit = createEvent({ + named: 'reinit', + }) store.reset(store.reinit) } + reportDeclaration(store.graphite) + return store } diff --git a/src/effector/index.ts b/src/effector/index.ts index 96081b421..7c9f23231 100644 --- a/src/effector/index.ts +++ b/src/effector/index.ts @@ -16,7 +16,7 @@ export {forward} from './forward' export {fromObservable} from './fromObservable' export {guard} from './guard' export {is} from './is' -export {launch} from './kernel' +export {launch, setInspector} from './kernel' export {merge} from './merge' export {restore} from './restore' export {sample} from './sample' @@ -24,6 +24,6 @@ export {setStoreName} from './naming' export {split} from './split' export {step} from './step' export {version} from './flags' -export {withRegion, withFactory} from './region' +export {withRegion, withFactory, setGraphInspector} from './region' export {hydrate, serialize, scopeBind, fork, allSettled} from './fork' export {createWatch} from './createWatch' \ No newline at end of file diff --git a/src/effector/inspect.ts b/src/effector/inspect.ts new file mode 100644 index 000000000..afae71154 --- /dev/null +++ b/src/effector/inspect.ts @@ -0,0 +1,238 @@ +import { + Scope, + Subscription, + Stack, + Node, + // private + // @ts-expect-error + setInspector, + // private + // @ts-expect-error + setGraphInspector, +} from 'effector' + +type Loc = { + file: string + line: number + column: number +} + +type NodeCommonMeta = { + kind: string + sid?: string + id: string + name?: string + loc?: Loc + meta: Record + derived?: boolean +} + +// Watch calculations +type Message = { + type: 'update' | 'error' + error?: unknown + value: unknown + stack: Record + trace?: Message[] +} & NodeCommonMeta + +const inspectSubs = new Set<{ + scope?: Scope + trace?: boolean + fn: (message: Message) => void +}>() + +setInspector((stack: Stack, local: {fail: boolean; failReason?: unknown}) => { + const {scope} = stack + inspectSubs.forEach(config => { + if ( + !( + // must be the same scope + ( + config.scope === scope || + // or no scope at all + (!config.scope && !scope) + ) + ) + ) { + /** + * Inspection is restriced by scope + */ + return + } + + config.fn({ + type: local.fail ? 'error' : 'update', + value: stack.value, + error: local.fail ? local.failReason : undefined, + stack: stack.meta || {}, + trace: config.trace ? collectMessageTrace(stack) : [], + ...getNodeMeta(stack), + }) + }) +}) + +export function inspect(config: { + scope?: Scope + trace?: boolean + fn: (message: Message) => void +}): Subscription { + inspectSubs.add(config) + + return createSubscription(() => { + inspectSubs.delete(config) + }) +} + +// Track declarations and graph structure +type Region = + | { + type: 'region' + meta: Record + region?: Region + } + | { + type: 'factory' + meta: Record + region?: Region + sid?: string + name?: string + method?: string + loc?: { + file: string + line: number + column: number + } + } + +type UnitDeclaration = { + type: 'unit' + meta: Record + region?: Region +} & NodeCommonMeta + +type Declaration = UnitDeclaration | Region + +const inspectGraphSubs = new Set<{ + fn: (declaration: Declaration) => void +}>() + +setGraphInspector((node: Node | 'region', regionStack: RegionStack) => { + let decl: Declaration | undefined + + if (node === 'region') { + decl = readRegionStack(regionStack) + } else { + decl = readUnitDeclaration(node, regionStack) + } + + if (decl) { + inspectGraphSubs.forEach(sub => { + sub.fn(decl!) + }) + } +}) + +export function inspectGraph(config: { + fn: (declaration: Declaration) => void +}): Subscription { + inspectGraphSubs.add(config) + return createSubscription(() => { + inspectGraphSubs.delete(config) + }) +} + +// Utils +function createSubscription(cleanup: () => void): Subscription { + const result = () => cleanup() + result.unsubscribe = result + return result +} + +function getNodeMeta(stack: Stack) { + const {node} = stack + + return readNodeMeta(node) +} + +function readNodeMeta(node: Node): NodeCommonMeta { + const {meta, id} = node + const loc = getLoc(meta) + const {sid, name, op: kind} = meta + + return {meta, id, sid, name, kind, loc, derived: meta.derived} +} + +function getLoc(meta: Record) { + return meta.config ? (meta.config as any).loc : meta.loc +} + +function collectMessageTrace(stack: Stack) { + const trace: Message[] = [] + let currentStack = stack.parent + + while (currentStack) { + trace.push({ + type: 'update', + value: currentStack.value, + stack: currentStack.meta || {}, + ...getNodeMeta(currentStack), + }) + + currentStack = currentStack.parent + } + + return trace +} + +function readUnitDeclaration( + node: Node, + regionStack: RegionStack, +): UnitDeclaration { + const nodeMeta = readNodeMeta(node) + + return { + type: 'unit', + region: readRegionStack(regionStack), + ...nodeMeta, + } +} + +function readRegionStack(regionStack?: RegionStack | null): Region | undefined { + if (!regionStack) return + const {parent, meta} = regionStack + const parentRegion = readRegionStack(parent) || undefined + + if (meta.type === 'factory') { + const {sid, name, loc, method} = meta as any + + return { + type: 'factory', + region: parentRegion, + meta, + sid, + name, + loc, + method, + } + } + + return { + type: 'region', + region: parentRegion, + meta, + } +} + +type RegionStack = { + parent: RegionStack | null + meta: + | Record + | { + type: 'factory' + sid?: string + name?: string + method?: string + loc?: Loc + } +} diff --git a/src/effector/kernel.ts b/src/effector/kernel.ts index 079f7e907..f2481f63a 100644 --- a/src/effector/kernel.ts +++ b/src/effector/kernel.ts @@ -42,6 +42,7 @@ type QueueBucket = { /** Dedicated local metadata */ type Local = { fail: boolean + failReason?: unknown scope: {[key: string]: any} } @@ -227,6 +228,13 @@ export const getPageRef = ( return ref } +/** Introspection api internals */ +type Inspector = (stack: Stack, local: Local) => void +let inspector: Inspector +export const setInspector = (newInspector: Inspector) => { + inspector = newInspector +} + export function launch(config: { target: NodeUnit | NodeUnit[] params?: any @@ -407,6 +415,9 @@ export function launch(unit: any, payload?: any, upsert?: boolean) { } stop = local.fail || skip } + if (inspector) { + inspector(stack, local) + } if (!stop) { const finalValue = getValue(stack) const forkPage = getForkPage(stack) @@ -542,5 +553,6 @@ const tryRun = (local: Local, fn: Function, stack: Stack) => { } catch (err) { console.error(err) local.fail = true + local.failReason = err } } diff --git a/src/effector/region.ts b/src/effector/region.ts index 308a068db..effe43b13 100644 --- a/src/effector/region.ts +++ b/src/effector/region.ts @@ -1,17 +1,43 @@ import type {Template} from '../forest/index.h' -import type {NodeUnit} from './index.h' -import {getParent, getMeta} from './getter' +import type {NodeUnit, Node} from './index.h' +import {getParent, getMeta, getGraph} from './getter' import {createNode} from './createNode' +type DeclarationSourceReporter = ( + node: Node | "region", + regionStack: RegionStack | null, +) => void + +let reporter: DeclarationSourceReporter + +export const setGraphInspector = (fn: DeclarationSourceReporter) => { + reporter = fn +} + type RegionStack = { parent: RegionStack | null value: any template: Template | null sidRoot?: string + meta: + | Record + | { + type: 'factory' + sid?: string + name?: string + loc: unknown + method?: string + } } export let regionStack: RegionStack | null = null +export const reportDeclaration = (node: Node | "region") => { + if (reporter) { + reporter(node, regionStack) + } +} + export const readTemplate = (): Template | null => regionStack && regionStack.template export const readSidRoot = (sid?: string | null) => { @@ -21,15 +47,19 @@ export const readSidRoot = (sid?: string | null) => { } export function withRegion(unit: NodeUnit, cb: () => void) { + const meta = getGraph(unit).meta || {} + regionStack = { parent: regionStack, value: unit, - template: getMeta(unit, 'template') || readTemplate(), - sidRoot: getMeta(unit, 'sidRoot') || (regionStack && regionStack.sidRoot), + template: meta.template || readTemplate(), + sidRoot: meta.sidRoot || (regionStack && regionStack.sidRoot), + meta: meta, } try { return cb() } finally { + reportDeclaration("region") regionStack = getParent(regionStack) } } @@ -47,8 +77,9 @@ export const withFactory = ({ method?: string fn: () => any }) => { - const sidNode = createNode({ - meta: {sidRoot: readSidRoot(sid), name, loc, method}, + const factoryRootNode = createNode({ + meta: {sidRoot: readSidRoot(sid), sid, name, loc, method, type: 'factory'}, }) - return withRegion(sidNode, fn) + + return withRegion(factoryRootNode, fn) } diff --git a/tsconfig.json b/tsconfig.json index 6dc27fef9..c686409a6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,7 @@ "effector/fixtures/prettyHtml": ["src/fixtures/prettyHtml.d.ts"], "effector/fixtures": ["src/fixtures/index.d.ts"], "effector": ["packages/effector/index.d.ts"], + "effector/inspect": ["packages/effector/inspect.d.ts"], "effector-react/scope": ["packages/effector-react/scope.d.ts"], "effector-react/ssr": ["packages/effector-react/scope.d.ts"], "effector-react": ["packages/effector-react/index.d.ts"], diff --git a/website/client/sidebars.js b/website/client/sidebars.js index 8d98d6cb7..15e503151 100644 --- a/website/client/sidebars.js +++ b/website/client/sidebars.js @@ -88,6 +88,7 @@ const sidebar = { 'api/effector/clearNode', 'api/effector/withRegion', 'api/effector/launch', + 'api/effector/inspect' ], }, ],