Skip to content

traceScenario records successful requests as "abandoned" when used with firstValueFrom, take(1) or first() #26

Closed

Description

@lukasmatta

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 lastValueFrom or .subscribe() instead of firstValueFrom.
  • Don't put take or first around traceScenario.
  • Put retry before traceScenario.

Tests to add

  • firstValueFrom, lastValueFrom, and take(1) / first() before and after the operator, against a real scenario rather than a mock.
  • A switchMap that supersedes a stream before and after it has emitted a value.
  • retry placed before and after the operator.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions