Skip to content

Commit d27f97d

Browse files
alxhubthePunderWoman
authored andcommitted
refactor(core): introduce TracingService for snapshot-based tracing (angular#58771)
This commit introduces a private API, the `TracingService` DI token. By providing this token, Angular can be configured to capture tracing snapshots for certain operations such as change detection notifications, and to run downstream operations within the context of those snapshots. `TracingService` abstracts this context propagation and makes it pluggable. PR Close angular#58771
1 parent 5b75d75 commit d27f97d

File tree

7 files changed

+86
-5
lines changed

7 files changed

+86
-5
lines changed

packages/core/src/application/application_ref.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {isPromise} from '../util/lang';
4545
import {NgZone} from '../zone/ng_zone';
4646

4747
import {ApplicationInitStatus} from './application_init';
48+
import {TracingService} from './tracing';
4849
import {EffectScheduler} from '../render3/reactivity/root_effect_scheduler';
4950

5051
/**
@@ -311,6 +312,7 @@ export class ApplicationRef {
311312
private readonly afterRenderManager = inject(AfterRenderManager);
312313
private readonly zonelessEnabled = inject(ZONELESS_ENABLED);
313314
private readonly rootEffectScheduler = inject(EffectScheduler);
315+
private readonly tracing = inject(TracingService, {optional: true});
314316

315317
/**
316318
* Current dirty state of the application across a number of dimensions (views, afterRender hooks,
@@ -329,6 +331,16 @@ export class ApplicationRef {
329331
*/
330332
deferredDirtyFlags = ApplicationRefDirtyFlags.None;
331333

334+
/**
335+
* Most recent snapshot from the `TracingService`, if any.
336+
*
337+
* This snapshot attempts to capture the context when `tick()` was first scheduled. It then runs
338+
* wrapped in this context.
339+
*
340+
* @internal
341+
*/
342+
tracingSnapshot: unknown | undefined = undefined;
343+
332344
// Needed for ComponentFixture temporarily during migration of autoDetect behavior
333345
// Eventually the hostView of the fixture should just attach to ApplicationRef.
334346
private externalTestViews: Set<InternalViewRef<unknown>> = new Set();
@@ -577,11 +589,15 @@ export class ApplicationRef {
577589
if (!this.zonelessEnabled) {
578590
this.dirtyFlags |= ApplicationRefDirtyFlags.ViewTreeGlobal;
579591
}
580-
this._tick();
592+
593+
// Run `_tick()` in the context of the most recent snapshot, if one exists.
594+
this.tracing?.run(this._tick, this.tracingSnapshot) ?? this._tick();
581595
}
582596

583597
/** @internal */
584-
_tick(): void {
598+
_tick = (): void => {
599+
this.tracingSnapshot = undefined;
600+
585601
(typeof ngDevMode === 'undefined' || ngDevMode) && this.warnIfDestroyed();
586602
if (this._runningTick) {
587603
throw new RuntimeError(
@@ -608,7 +624,7 @@ export class ApplicationRef {
608624
setActiveConsumer(prevConsumer);
609625
this.afterTick.next();
610626
}
611-
}
627+
};
612628

613629
/**
614630
* Performs the core work of synchronizing the application state with the UI, resolving any
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {InjectionToken} from '../di/injection_token';
10+
11+
/**
12+
* Injection token for a `TracingService`, optionally provided.
13+
*/
14+
export const TracingService = new InjectionToken<TracingService<unknown>>('');
15+
16+
/**
17+
* Tracing mechanism which can associate causes (snapshots) with runs of subsequent operations.
18+
*
19+
* Not defined by Angular directly, but defined in contexts where tracing is desired.
20+
*/
21+
export interface TracingService<TSnapshot> {
22+
/**
23+
* Take a snapshot of the current context which will be stored by Angular and used when additional
24+
* work is performed that was scheduled in this context.
25+
*/
26+
snapshot(): TSnapshot;
27+
28+
/**
29+
* Invoke `fn` within the given tracing snapshot, which may be `undefined`.
30+
*
31+
* This _must_ return the result of the function invocation.
32+
*/
33+
run<T>(fn: () => T, snapshot: TSnapshot | undefined): T;
34+
}

packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
ZONELESS_SCHEDULER_DISABLED,
3131
SCHEDULE_IN_ROOT_ZONE,
3232
} from './zoneless_scheduling';
33+
import {TracingService} from '../../application/tracing';
3334

3435
const CONSECUTIVE_MICROTASK_NOTIFICATION_LIMIT = 100;
3536
let consecutiveMicrotaskNotifications = 0;
@@ -60,6 +61,7 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
6061
private readonly taskService = inject(PendingTasksInternal);
6162
private readonly ngZone = inject(NgZone);
6263
private readonly zonelessEnabled = inject(ZONELESS_ENABLED);
64+
private readonly tracing = inject(TracingService, {optional: true});
6365
private readonly disableScheduling =
6466
inject(ZONELESS_SCHEDULER_DISABLED, {optional: true}) ?? false;
6567
private readonly zoneIsDefined = typeof Zone !== 'undefined' && !!Zone.root.run;
@@ -192,6 +194,10 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
192194
}
193195
}
194196

197+
// If not already defined, attempt to capture a tracing snapshot of this notification so that
198+
// the resulting CD run can be attributed to the context which produced the notification.
199+
this.appRef.tracingSnapshot ??= this.tracing?.snapshot();
200+
195201
if (!this.shouldScheduleTick(force)) {
196202
return;
197203
}

packages/core/src/core_private_export.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export {
1717
IMAGE_CONFIG_DEFAULTS as ɵIMAGE_CONFIG_DEFAULTS,
1818
ImageConfig as ɵImageConfig,
1919
} from './application/application_tokens';
20+
export {TracingService as ɵTracingService} from './application/tracing';
2021
export {internalCreateApplication as ɵinternalCreateApplication} from './application/create_application';
2122
export {
2223
defaultIterableDiffers as ɵdefaultIterableDiffers,

packages/core/src/render3/after_render/hooks.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import {TracingService} from '../../application/tracing';
910
import {assertInInjectionContext} from '../../di';
1011
import {Injector} from '../../di/injector';
1112
import {inject} from '../../di/injector_compatibility';
@@ -454,13 +455,16 @@ function afterRenderImpl(
454455
// tree-shaken if `afterRender` and `afterNextRender` aren't used.
455456
manager.impl ??= injector.get(AfterRenderImpl);
456457

458+
const tracing = injector.get(TracingService, null, {optional: true});
459+
457460
const hooks = options?.phase ?? AfterRenderPhase.MixedReadWrite;
458461
const destroyRef = options?.manualCleanup !== true ? injector.get(DestroyRef) : null;
459462
const sequence = new AfterRenderSequence(
460463
manager.impl,
461464
getHooks(callbackOrSpec, hooks),
462465
once,
463466
destroyRef,
467+
tracing?.snapshot(),
464468
);
465469
manager.impl.register(sequence);
466470
return sequence;

packages/core/src/render3/after_render/manager.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
NotificationSource,
1717
} from '../../change_detection/scheduling/zoneless_scheduling';
1818
import {type DestroyRef} from '../../linker/destroy_ref';
19+
import {TracingService} from '../../application/tracing';
1920

2021
export class AfterRenderManager {
2122
impl: AfterRenderImpl | null = null;
@@ -44,6 +45,7 @@ export class AfterRenderImpl {
4445
private readonly ngZone = inject(NgZone);
4546
private readonly scheduler = inject(ChangeDetectionScheduler);
4647
private readonly errorHandler = inject(ErrorHandler, {optional: true});
48+
private readonly tracing = inject(TracingService, {optional: true});
4749

4850
/** Current set of active sequences. */
4951
private readonly sequences = new Set<AfterRenderSequence>();
@@ -68,7 +70,10 @@ export class AfterRenderImpl {
6870

6971
try {
7072
sequence.pipelinedValue = this.ngZone.runOutsideAngular(() =>
71-
sequence.hooks[phase]!(sequence.pipelinedValue),
73+
this.maybeTrace(
74+
() => sequence.hooks[phase]!(sequence.pipelinedValue),
75+
sequence.snapshot,
76+
),
7277
);
7378
} catch (err) {
7479
sequence.erroredOrDestroyed = true;
@@ -124,6 +129,11 @@ export class AfterRenderImpl {
124129
}
125130
}
126131

132+
protected maybeTrace<T>(fn: () => T, snapshot: unknown): T {
133+
// Only trace the execution if the snapshot is defined.
134+
return this.tracing && snapshot ? this.tracing.run(fn, snapshot) : fn();
135+
}
136+
127137
/** @nocollapse */
128138
static ɵprov = /** @pureOrBreakMyCode */ /* @__PURE__ */ ɵɵdefineInjectable({
129139
token: AfterRenderImpl,
@@ -160,13 +170,19 @@ export class AfterRenderSequence implements AfterRenderRef {
160170
readonly hooks: AfterRenderHooks,
161171
public once: boolean,
162172
destroyRef: DestroyRef | null,
173+
public snapshot: unknown,
163174
) {
164175
this.unregisterOnDestroy = destroyRef?.onDestroy(() => this.destroy());
165176
}
166177

167178
afterRun(): void {
168179
this.erroredOrDestroyed = false;
169180
this.pipelinedValue = undefined;
181+
182+
// Clear the tracing snapshot after the initial run. This snapshot only associates the initial
183+
// run of the hook with the context that created it. Follow-up runs are independent of that
184+
// initial context and have different triggers.
185+
this.snapshot = undefined;
170186
}
171187

172188
destroy(): void {

packages/core/src/render3/reactivity/after_render_effect.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {NOOP_AFTER_RENDER_REF, type AfterRenderOptions} from '../after_render/ho
3636
import {DestroyRef} from '../../linker/destroy_ref';
3737
import {assertNotInReactiveContext} from './asserts';
3838
import {assertInInjectionContext} from '../../di/contextual';
39+
import {TracingService} from '../../application/tracing';
3940

4041
const NOT_SET = Symbol('NOT_SET');
4142
const EMPTY_CLEANUP_SET = new Set<() => void>();
@@ -175,10 +176,11 @@ class AfterRenderEffectSequence extends AfterRenderSequence {
175176
effectHooks: Array<AfterRenderPhaseEffectHook | undefined>,
176177
readonly scheduler: ChangeDetectionScheduler,
177178
destroyRef: DestroyRef,
179+
snapshot: unknown,
178180
) {
179181
// Note that we also initialize the underlying `AfterRenderSequence` hooks to `undefined` and
180182
// populate them as we create reactive nodes below.
181-
super(impl, [undefined, undefined, undefined, undefined], false, destroyRef);
183+
super(impl, [undefined, undefined, undefined, undefined], false, destroyRef, snapshot);
182184

183185
// Setup a reactive node for each phase.
184186
for (const phase of AFTER_RENDER_PHASES) {
@@ -370,6 +372,7 @@ export function afterRenderEffect<E = never, W = never, M = never>(
370372
const injector = options?.injector ?? inject(Injector);
371373
const scheduler = injector.get(ChangeDetectionScheduler);
372374
const manager = injector.get(AfterRenderManager);
375+
const tracing = injector.get(TracingService, null, {optional: true});
373376
manager.impl ??= injector.get(AfterRenderImpl);
374377

375378
let spec = callbackOrSpec;
@@ -382,6 +385,7 @@ export function afterRenderEffect<E = never, W = never, M = never>(
382385
[spec.earlyRead, spec.write, spec.mixedReadWrite, spec.read] as AfterRenderPhaseEffectHook[],
383386
scheduler,
384387
injector.get(DestroyRef),
388+
tracing?.snapshot(),
385389
);
386390
manager.impl.register(sequence);
387391
return sequence;

0 commit comments

Comments
 (0)