Repository navigation
Description
Summary
A scenario traced with traceScenario settles as abandoned (abandonedBy: 'caller') instead of success when the stream is consumed with firstValueFrom, or when take(1) / first() follows the operator. The request succeeds and the caller receives the data, but the scenario record says the user abandoned the journey. The outcome mapper never runs, so its metadata is lost too.
await firstValueFrom(http.get(...)) is the idiomatic pattern in modern Angular, and the README's examples use async/await, so this might affect most real usage. The result is skewed reliability metrics: success rate is under-reported and abandonment rate is inflated. Nothing in the output looks wrong, so it is easy to miss.
Minimal reproduction
Drop this into projects/ngx-ui-watchtower/src/lib/ and run npx jest trace-scenario-first-value:
import { TestBed } from '@angular/core/testing';
import { firstValueFrom, of, take } from 'rxjs';
import {
provideUwtTelemetry,
provideUwtTelemetrySink,
traceScenario,
UWT_LOG_API_PROVIDER,
UwtNoopLogApiProvider,
UwtScenarioTelemetryService
} from '@absaoss-cps/ngx-ui-watchtower';
describe('traceScenario', () => {
let scenarios: UwtScenarioTelemetryService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideUwtTelemetry({ application: 'repro', environment: 'test', version: '0' }),
provideUwtTelemetrySink('noop'),
{ provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider }
]
});
scenarios = TestBed.inject(UwtScenarioTelemetryService);
});
it('settles as success with firstValueFrom', async () => {
const scenario = scenarios.start({ name: 'load-customers' });
const rows = await firstValueFrom(of(['a', 'b']).pipe(traceScenario(scenario)));
expect(rows).toEqual(['a', 'b']); // the request worked
expect(scenario.status).toBe('success'); // received: 'abandoned'
});
it('settles as success with take(1) after the operator', () => {
const scenario = scenarios.start({ name: 'load-customers' });
of(['a', 'b']).pipe(traceScenario(scenario), take(1)).subscribe();
expect(scenario.status).toBe('success'); // received: 'abandoned'
});
});Expected: success
Actual: abandoned, with metadata: { abandonedBy: 'caller' }
of(...) stands in for HttpClient; both emit one value and then complete.
Cause
traceScenario (uwt-scenario-operators.ts) settles in three tap hooks: complete → scenario.complete(), error → scenario.fail(), and unsubscribe → scenario.cancel().
firstValueFrom resolves on the first value and unsubscribes inside its next handler, before the source's complete is delivered. tap sees a teardown without a complete, so its unsubscribe hook calls cancel(). By the time the source completes, the subscriber is closed and the scenario is already settled.
take(1) and first() behave the same way after one value: they complete downstream but unsubscribe upstream. Moving take(1) before the operator does not help under firstValueFrom, because the teardown still happens inside the emit.
| Usage | Recorded status |
|---|---|
.subscribe() |
success |
lastValueFrom(src.pipe(traceScenario(s))) |
success |
firstValueFrom(src.pipe(traceScenario(s))) |
abandoned |
src.pipe(traceScenario(s), take(1)) |
abandoned |
src.pipe(traceScenario(s), first()) |
abandoned |
firstValueFrom(src.pipe(take(1), traceScenario(s))) |
abandoned |
The existing specs don't catch this. uwt-scenario-operators.spec.ts only uses .subscribe() with a mocked scenario. The one firstValueFrom + traceScenario test is in uwt-telemetry-enabled.spec.ts, where telemetry is disabled and the scenario is inert.
Proposed fix
A teardown after the stream has delivered a value means the consumer got what it needed, so treat it as completion. A teardown before any value stays a cancellation (switchMap superseding an in-flight request, takeUntilDestroyed, etc.).
unsubscribe: () => {
if (scenario.isSettled) return;
if (hasValue && valueThenTeardown === 'complete') {
const outcome = outcomeMapper
? uwtSafe('scenario.traceOutcome', () => outcomeMapper(lastValue as T), undefined)
: undefined;
scenario.complete(outcome || undefined);
return;
}
// existing cancel path, unchanged
}Add an opt-out to UwtTraceScenarioOptions for multi-value streams (polling, progress), where a teardown after a value really is abandonment:
/** What a downstream teardown means once a value has been delivered. Default 'complete'. */
valueThenTeardown?: 'complete' | 'cancel';With this change, all existing tests pass and every row in the table above records success with the mapper's metadata.
An alternative is to keep the current default and add an opt-in settleOn: 'first-value' option, or a separate operator for single-value sources. That leaves the trap as the default for the most common case.
Related: retry() placement
src.pipe(traceScenario(s), retry(1)) records failure even when the retry succeeds. The first error settles the scenario, and the operator can't see the downstream retry. src.pipe(retry(1), traceScenario(s)) records correctly. That ordering should be documented, in the README and in DESIGN.md's "RxJS streams and traceScenario" section.
Workarounds until fixed
- Use
lastValueFromor.subscribe()instead offirstValueFrom. - Don't put
takeorfirstaroundtraceScenario. - Put
retrybeforetraceScenario.
Tests to add
firstValueFrom,lastValueFrom, andtake(1)/first()before and after the operator, against a real scenario rather than a mock.- A
switchMapthat supersedes a stream before and after it has emitted a value. retryplaced before and after the operator.
Activity