RFC: Opt-in causal scope propagation for saga-driven action chains
RFC: Opt-in causal scope propagation for saga-driven action chains
Summary
I would like to propose an opt-in mechanism for propagating a causal "scope" through saga-driven action chains.
The goal is to make it easier to correlate all actions caused by one original interaction without manually passing trace metadata through every action.
For example:
dispatch({ type: 'CHECKOUT_STARTED' })
A saga may take this action and emit another action:
function* checkoutFlow() {
const action = yield take('CHECKOUT_STARTED')
yield put({ type: 'PAYMENT_STARTED' })
}
With scope propagation enabled, both actions would share the same causal scope id:
CHECKOUT_STARTED -> scope.id = 1
PAYMENT_STARTED -> scope.id = 1
This becomes more useful as the chain grows:
UI dispatches ACTION_A
saga takes ACTION_A
saga puts ACTION_B
another saga takes ACTION_B
saga puts ACTION_C
Today, applications usually need to manually pass metadata through every action to correlate such chains.
Motivation
In real applications, a single user interaction can trigger many saga-handled actions.
Examples:
- checkout flows
- multi-step form submissions
- authentication flows
- websocket/retry flows
- analytics and logging flows
- debugging production issues across multiple saga transitions
It is useful to know that multiple actions belong to the same causal chain.
Currently, this usually requires doing something like:
yield put({
type: 'PAYMENT_STARTED',
meta: {
traceId: action.meta.traceId,
},
})
This becomes repetitive and easy to miss as the flow grows.
Why sagaMonitor was not enough
I explored whether this can be solved using sagaMonitor.
sagaMonitor is useful for observing saga effects, but it gives effect-level relationships. In my experiments, each put was observed as an independent effect, and the parent effect relationship did not directly represent the business-causal action chain I wanted to trace.
The goal here is different from monitoring the saga execution tree.
The goal is to propagate a user-defined causal scope through:
take -> put -> take -> put
so that applications can correlate all actions caused by the same original interaction.
Proposed behavior
This feature would be opt-in.
Possible API:
const sagaMiddleware = createSagaMiddleware({
scope: true,
})
Or a more configurable version:
const sagaMiddleware = createSagaMiddleware({
scope: {
enabled: true,
createScope: action => ({ id: createScopeId() }),
},
})
When enabled:
- An incoming action without an existing scope receives a new scope.
- A saga that takes a scoped action gets access to that scope.
- Actions emitted through
putinherit the current saga scope. - A saga can update the current scope for downstream effects.
Example:
import { getScope, updateScope } from 'redux-saga/effects'
function* checkoutFlow() {
const action = yield take('CHECKOUT_STARTED')
const scope = yield getScope()
yield updateScope({
checkoutId: action.payload.checkoutId,
})
yield put({ type: 'PAYMENT_STARTED' })
}
The emitted action would carry the same scope id, plus the updated scope data.
Conceptually:
CHECKOUT_STARTED
scope: { id: 1 }
PAYMENT_STARTED
scope: { id: 1, checkoutId: 'abc' }
Possible internal representation
The scope could be stored internally using a Symbol to avoid colliding with userland action fields.
For example, internally:
action[SCOPE] = {
id: 1,
}
However, users probably should not need to access the Symbol directly.
Possible public APIs could be:
yield getScope()
yield updateScope(partialScope)
And possibly, for action inspection:
getActionScope(action)
This would avoid encouraging direct access to the internal Symbol.
Important semantic questions
I would like feedback on the desired semantics before working on a full implementation.
1. Should a task adopt the scope of an action returned by take?
Example:
function* watcher() {
while (true) {
const action = yield take('ACTION_A')
yield put({ type: 'ACTION_B' })
}
}
Should ACTION_B inherit the scope from ACTION_A?
I think yes, because this is the main use case.
2. How should fork behave?
Should a forked task inherit a snapshot of the parent scope?
Possible behavior:
parent task scope: { id: 1 }
yield fork(worker)
worker starts with scope: { id: 1 }
My current thinking is that fork should inherit a snapshot of the current scope.
3. Should parallel forks be isolated?
Example:
yield fork(workerA)
yield fork(workerB)
If workerA calls:
yield updateScope({ branch: 'A' })
Should workerB see that update?
I think no. Each fork should receive a snapshot, and later scope updates should not leak across parallel branches.
4. How should call behave?
Since call is part of the same execution chain, it probably should use the same current task scope.
5. How should spawn behave?
Since spawn creates a detached task, should it:
- inherit the initial scope snapshot, or
- start without scope unless explicitly provided?
This seems worth discussing.
6. Should scope metadata be enumerable?
If scope is stored on the action using a Symbol, should it be enumerable or non-enumerable?
A non-enumerable Symbol may avoid polluting logs, Redux DevTools, and normal action inspection.
7. Should redux-saga generate scope ids?
A simple implementation could use incrementing ids, but applications may want custom ids such as:
- request ids
- trace ids
- UUIDs
- OpenTelemetry ids
- server-provided correlation ids
So maybe scope id generation should be configurable:
createSagaMiddleware({
scope: {
createScope: action => ({
id: action.meta && action.meta.requestId
? action.meta.requestId
: createScopeId(),
}),
},
})
Why this may belong in redux-saga
This behavior is difficult to implement reliably in userland because the scope needs to follow saga execution semantics.
In particular, it needs to account for:
takeputcallforkspawn- nested saga flows
- parallel branches
- manually dispatched root actions
- actions emitted by sagas
Since redux-saga already owns the effect runtime, it seems like the runtime is the right place to define this behavior if the feature is considered useful.
Prior art / related concepts
This is conceptually similar to trace context propagation in backend systems, where a request id or trace id is passed through downstream calls.
Here, the equivalent chain is not HTTP calls, but saga-driven action transitions.
Current status
I have a local prototype to validate the idea, but I would like to discuss the desired API and semantics before turning it into a full PR.
In particular, I would like feedback on:
- whether this feature fits redux-saga core
- whether it should instead be implemented through existing extension points
- the preferred API shape
- the correct semantics for
take,put,fork,call, andspawn - whether Symbol-backed action metadata is acceptable
Would love to hear your thoughts on whether this direction fits redux-saga, and whether there is a better way to approach this using existing extension points.
If the direction seems reasonable, I’d be happy to clean up the prototype and explore a draft PR.
Source: redux-saga/redux-saga