redux-saga/redux-saga

DevTools for Sagas

gaearon opened this issue ยท 48 comments

Redux DevTools currently leave a lot to be desired.
Mainly because they don't track async interactions at all.

It's impossible to say which component caused which async action, which in turn caused other async actions, which in turn dispatched some regular actions.

However sagas seem different. They make side effect control flow more explicit and don't execute side effects unless the caller demands so. If we wrapped top level functions such as take(), call(), put(), fork(), into tracking functions, we could potentially build a graph of saga control flow and trace action history through the sagas that generated it. This is similar to Cerebral debugger UI which unlike ours can trace which Signal was caused by which other Signal.

The developer wins in my opinion could be huge here. If this is possible to build we need to build it. At first as a proof of concept with console logging, and later with a UI, possibly as an extension to Redux DevTools.

What do you think? Am I making sense here? Have I missed something that makes this infeasible, not useful, or hard?

What do you think? Am I making sense here? Have I missed something that makes this infeasible, not useful, or hard?

If I understand it's about 2 things

  • first being able to trace each effect yielded by the Saga
  • second is being able to build some kind of control-flow model from the above list

The first thing seems easy to implement. However we can't do that by wrapping effect creators (take, put, ...) because they are just pure functions so they are not aware of the context that created them (in test or in real code). Instead it can be added in the proc function (the one that drives the generator).

We can for example make the middleware accepts an effectMonitor callback, the effectMonitor will be called each time an effect is executed like effectMonitor (nameOfSaga, effect) (in replay sessions the Devtools can simply replay with the recorded results). And so you can process the log data in the way you like: log into the console, render in an UI or whatever else.

The second thing: I need to more understand the desired output: this control flow graph

we could potentially build a graph of saga control flow and trace action history through the sagas that generated it.

By graph of saga control flow do you mean a call hierarchy of Sagas -> child Sagas/functions ? so we can for example log something like (* to distinguish generators from normal functions)

*watchFetch
   action FETCH_POSTS
   *fetchPosts
         action REQUEST_POSTS
         call (fetchPosts)
         action RECEIVE_POSTS

At first glance this seems doable, for example we can add a field which is the current call path. So now we can call the effectMonitor(currentCalPath, effect)

so for the sample above it would give something like

// flat list of effects
[
  saga: '*watchFetch', args: [], path ''
  action: FETCH_POSTS, path:  ['*watchFetch']
  call: *fetchPosts, args: [], path: ['*watchFetch']
  action REQUEST_POSTS, path: ['*watchFetch/*fetchPosts']
  ...
]

And the log handler can transform this list into the desired control flow

This is kind of a quick thinking. We need also to represent other kind of effects in the control flow model : paraellel effects, race, fork, join

So to recap

  • The Saga driver can provides all data that it processes to a logger callback
  • The main question is how to map all the kind of effects into that control flow graph

EDIT: sorry fixed the last list

At first glance this seems doable, for example we can add a field which is the current call path. So now we can call the effectMonitor(currentCalPath, effect)

Basically I was thinking about giving each saga an ID (internally) and notifying which saga is parent to which saga by embedding "parent ID" into every parent-child effect. So this would give us the call tree.

let's see if I understood you correctly. I'll call the Sagas/effects tasks in the following

When started, each task is giving an ID, and optionally the parent's ID of an already started task

Each time a task is started I notify the monitor with something like monitor.taskcreated(id, parentId, taskDesc) and now we have a hierarchy of tasks

Each time a task yields an effect I call the monitor.effectTriggered(taskId, effectId, effectDesc) so now the monitor can locate in which place in the call tree the effect was issued

And for tasks that return results (usually promised functions) when the result is resolved I call monitor.effectResolved/Rejected(taskId, effectId, result)

I saw the cerebral video and a couple of others. It seems the fact that the control flow being described declaratively helps a lot for tracing each operation. and with the state atom and cursor like operations every single mutation can be traced. In Redux the finest you can get is at the action level (which can leads to multiple mutations). More flexibility but less control.

Hi guys! Just wanted to add an updated video on the Cerebral Debugger, which takes into account parallell requests, paths etc. Really glad it can inspire and really looking forward to see where this is going :-) https://www.youtube.com/watch?v=QhStJqngBXc

Hello,

When implementing devtools with Sagas, make sure that when you replay events the saga does not kick in and triggers new actions (replaying history should not modify that history and it could be easy to do so with sagas).

Basically I was thinking about giving each saga an ID (internally) and notifying which saga is parent to which saga by embedding "parent ID" into every parent-child effect. So this would give us the call tree.

@gaearon Can you please elaborate on this? Did I understand it correctly, that you would like to have an user interaction defined transaction boundary? Let's say onClick these actions have been dispatched:

  • CLICKED_FOO
  • API_STARTED
  • API_FINISHED

#5 (comment) describes what I mean pretty well. Implementation wise I'd experiment with creating a store enhancer that would track the relevant DevTools state in a separate reducer, so those calls to monitor are just actions. See this approach in regular Redux DevTools.

@gaearon in that case isn't this slight modification of redux-thunk melted into store enhancer exactly what we need?

const storeEnhancer = storeFactory => (reducer, initialState) => {
  let correlationId = 0;
  const store = storeFactory(reducer, initialState);

  const wrappedDispatch = action => {
    correlationId++;

    if (typeof action === 'function') {
      // The idea is to wrap dispatch only for thunks (which defines UI interaction transaction boundary)
      return action(dispatchable => store.dispatch({...dispatchable, correlationId}), store.getState);
    } else {
      return store.dispatch({...action, correlationId});
    }
  };

  return {
    ...store,
    dispatch: wrappedDispatch
  };
};

Given two action creators:

const simpleActionCreator = () => ({type: 'SIMPLE'});

and

const thunkActionCreator = (dispatch, getState) => {
  dispatch({type: 'THUNK_STEP_1'});
  dispatch({type: 'THUNK_STEP_2'});

  setTimeout(() => {
    dispatch({type: 'THUNK_STEP_3'});
  }, 500);
};

when called sequentially

dispatch(simpleActionCreator());
dispatch(thunkActionCreator);

will dispatch these actions:

[{type: 'SIMPLE', correlationId: 1},
{type: 'THUNK_STEP_1', correlationId: 2},
{type: 'THUNK_STEP_2', correlationId: 2},
{type: 'THUNK_STEP_3', correlationId: 2}]

Because the implementation is exclusive with thunk-middleware it must allow recursive thunk dispatching, therefore slight modification. The implementation does not break any middleware chain and all the middlewares get applied:

const storeEnhancer = storeFactory => (reducer, initialState) => {
  let correlationId = 0;
  const store = storeFactory(reducer, initialState);

  const thunkMiddlewareWithCorrelationId = id => action => {
    if (typeof action === 'function') {
      return action(thunkMiddlewareWithCorrelationId(id), store.getState);
    } else {
      return store.dispatch({...action, correlationId: id});
    }
  };

  const wrappedDispatch = action => {
    correlationId++;

    return thunkMiddlewareWithCorrelationId(correlationId)(action);
  };

  return {
    ...store,
    dispatch: wrappedDispatch
  };
};

EDIT: Reflecting the tree structure of correlation ids is fairly simple, you can display in devtools the exact async thunk hierarchy.

Also the cool thing about this is that it replaces redux-thunk for development, the functionality is no different except it provides some additional action metadata. Therefore we can use this enhancer for development and swap it for redux-thunk in production.

@gaearon I pushed a new branch for experimenting. This is not so big, but can serve as a starting point to refine the model

Right now, the middleware can dispatch low level actions (defined here). usage example here

This forms a kind of a DB with 2 tables: tasks and effects, with parent/child relation from tasks to effects, and a hierarchical parent/child relation on the tasks table itself. so we can construct different 'views' or 'queries' from this. Actually I can think of 2 different views

  • a view per Saga: we can watch saga progression (receiving actions, firing effects, nested sagas)
  • a view 'per action': means upon each UI-dispatched action, picks all sagas watching for that action and show their reactions below the triggered action; this is quite semblable to how Cerebral debugger work. But seems more challenging to implement (but not impossible)

Reworked the monitoring branch.

First, Sagas events are now dispatched as normal Redux actions, so you can handle them by a normal middleware and/or reducer.

Second there are only 3 actions: EFFECT_TRIGGERED, EFFECT_RESOLVED, EFFECT_REJECTED. Every effect is identified by 3 props: its Id, its parent ID (forked/called sagas, child effects in a yield race or a yield [...effects]) and optionally a label (to support yield race({label1: effect1, ... }))

There is an example of a saga monitor that watches all monitoring actions and update an internal tree. You can play with all examples (except real-world), by running the example and dispatching a {type: 'LOG_EFFECT'} action to the store whenever you want to print the flow log into the console (the store is exposed as a global variable to allow playing with it).

for example

npm run counter

// execute some actions
// in the console type this 
store.dispatch({type: 'LOG_EFFECT'})

Below a sample snabpshot

counter-log

Another snapshot from the shopping-cart example

shop-log

waouuuh I like the way you display the race :)

Happy you liked it! It took some tweaks but chrome console api is really awesome

This is amazing stuff! I've recently started to look at Redux, didn't like redux-thunk, found redux-saga, started thinking about how to make sagas more explicit, and here we are!

What I would love to see is a way to fully recreate the state of an app, not just the store/view, but also the implicit state of sagas.

Is the idea here that eventually you could do this by replaying the event stream, and whenever a saga calls a function, automagically turn that into a take on the event representing the effect being resolved?

I currently have an application that has all asynchronous logic in sagas. The problem I ran into is that the saga middleware spits out a huge amount of EFFECT_RESOLVED and EFFECT_TRIGGERED actions. This makes it hard to analyse the application state over time in the regular devtools. Any solution for this (maybe muting a way of muting these actions?).

@jfrolich I'm interested in this too.

Maybe we could mark some actions as being verbose, and the devtools could have a checkbox to only display those when we really want to? @gaearon ?

To be fair we already let you mute certain actions with https://github.com/zalmoxisus/redux-devtools-filter-actions. But Iโ€™m open to offering a built-in approach.

Awesome Dan, I should have checked for a solution before posting. Cheers!
One thing it does not solve is for instance if we also have a logger. For my quite simple app with sagas it is spitting out 1k actions. Maybe there is a way to reduce it?

@jfrolich redux-logger has a predicate in the config object to drop or print the given action:
https://github.com/fcomb/redux-logger#options

pke commented

Great work so far!
I wonder how difficult it would be to transform the sagaMonitor to an actual DevTools monitor.

pke commented

Some sagas appear as "unknown" in SagaMonitor. Anything I can do about that?

davej commented

@yelouafi Would you be interested in releasing https://github.com/yelouafi/redux-saga/blob/master/examples/sagaMonitor/index.js as a separate module on npm?

@davej Yes but unit tests are needed before making a separate release

@pke (sorry for the late answer) Those are either sagas called directly without the declarative form yield saga() instead of yield call(saga) or either using yield takeEvery(...) instead of yield* takeEvery(...) (the later could be fixed thou)

davej commented

@GantMan reactotron doesn't support sagas yet though, does it?

I know the guy working on it, and he's planning on making it extendable to support a variety of items. We use Sagas all the time (BECAUSE IT'S AWESOME) so it's on the roadmap.

As of now, just redux, but you can Reactotron.log in your sagas if you're desperate. I just found this UI way more useful than chrome, at this time. I figure the more demand the faster we'll deliver.

is there an example of using sagaMonitor with React Native?

Enhanced the sagaMonitor code to work in Node environment, for universal sagas.
Please take a look: #317

@sompylasar thanks, do you know how can I get the store or the dispatch function on chrome debug in React Native?

@sibeliusseraphini sorry, I don't work with React Native, but I think Reactotron should help you with that โ€“ store and the dispatch function come from Redux itself, not Redux-Saga, so not related to the sagaMonitor.

@sibeliusseraphini oh, and there is that: https://github.com/zalmoxisus/remote-redux-devtools

I've just tried @yelouafi sagaMonitor example, it's really nice! however when I try to log my sagas I see a couple of unnamed parallel tasks. I think it's caused by my rootSaga (composed saga) and some of its children (composed as the parent one). There is a way to "label" composed sagas (yield [ ... ])?

@pke @sompylasar I was getting "unknowns" as well. I traced it to "actionChannel", which isn't handled in getEffectLog. Consider adding else if (data = asEffect.actionChannel(effect.effect))

reem commented

Seems to me the hardest part about having dev tools for sagas is exposing the current state of the saga, rather than just the history of effects and what it is currently waiting on. For instance, observing my authentication saga I want to know what it thinks is the current user, not only that it is waiting on race(logout, refresh auth), but this is very difficult since the current user is just stored in a local variable.

Could somebody please post a full-ish example of how to use the monitor? Or link to the documentation? It looks amazing but my best attempt at using it was unsuccessful.

@emragins you can use the sagaMonitor defined in the redux-saga examples and pass it to createSagaMiddleware through the options object.

const sagaMiddleware = createSagaMiddleware({ sagaMonitor: yourSagaMonitor });

Now that the monitor is setup, you can call the logSaga from inside your code or easily the $$LogSagas from the console in your browser.

@yelouafi maybe just two lines more in docs will help ;)

Hello.

I've seen that @yelouafi wrote an wonderful tool for logging Effects, and gave examples, but I have questions to that comment:
#5 (comment)

There was attached pretty log like this:

I've connect sagaMonitor to my project and saw not so pretty log like above

So I've clone redux-saga-examples and run it, and... in console no logs at all.

I'm doing something wrong?

UPD. Yes, I thought that log in real time all my Effects, but it's not! Need to enter $$LogSagas() in your console and the tree will be displaing!
I've tried it before, but I've typed $$logSagas() instead of $$LogSagas(), because copy past that function from the comment above #5 (comment) from @Splact

Sorry @DimonTD for that, was just a typo mistake. (Fixed for next readers...)

@sibelius react native seems to run in a web worker when debugging in chrome https://corbt.com/posts/2015/12/19/debugging-with-global-variables-in-react-native.html you can access the global scope as mentioned in the article so all you have to do is the following somewhere in your code:

  // If react native
  if (typeof window.document === 'undefined' && navigator.product === 'ReactNative') {
    this.$$LogSagas = logSaga;
  }

is this abandoned? is another solution for this?

I think tools like Reactotron has some saga monitoring. There is also https://github.com/redux-saga/redux-saga-devtools , its graphical and working - but it needs polishing and more work, so its rather far from being finished and unfortunately noone is working on it at the moment. If you'd like to help, please reach out to me - project is quite straightforward and contributing to it shouldnt be too hard.

I packaged redux-saga-devtools into a Chrome extension for folks to use
https://github.com/abettadapur/redux-saga-devtools-extension

Would be better if just use redux-devtool, but currently,

  • all callstack for redux-saga is trapped inside the saga package, without returning the line from the source code
    image

  • the action dispatched from the Application layer has a clear callstack.
    image

If we can solve this, then it gonna be heaven.

๐Ÿ‘ for @Albert-Gao
He's right. Saga is very hard to debug without exposing its stack trace to the redux dev tools

I guess this never happened, but it would be such a boon compared with sifting through enormous, nearly unreadable callstacks.

I guess this never happened, but it would be such a boon compared with sifting through enormous, nearly unreadable callstacks.

I agree, at the very least, improving callstacks would be extremely helpful to the project. I'm going to raise this in the redux-saga in 2022 discussion.