Add runtime diagnostics probe for zone/tick

pull/1163/head
voc0der 2 months ago
parent a10030e6ee
commit f36f292568

@ -1,4 +1,4 @@
import { Component, OnInit, ElementRef, ViewChild, HostBinding, AfterViewInit } from '@angular/core';
import { Component, OnInit, ElementRef, ViewChild, HostBinding, AfterViewInit, ApplicationRef, NgZone } from '@angular/core';
import {MatDialogRef} from '@angular/material/dialog';
import {PostsService} from './posts.services';
import { MatDialog } from '@angular/material/dialog';
@ -14,6 +14,7 @@ import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-p
import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component';
import { NotificationsComponent } from './components/notifications/notifications.component';
import { ArchiveViewerComponent } from './components/archive-viewer/archive-viewer.component';
import { diagCount, diagEvent, getYdmDiag } from './diagnostics';
@Component({
selector: 'app-root',
@ -47,7 +48,10 @@ export class AppComponent implements OnInit, AfterViewInit {
notification_count = 0;
constructor(public postsService: PostsService, public snackBar: MatSnackBar, private dialog: MatDialog,
public router: Router, public overlayContainer: OverlayContainer, private elementRef: ElementRef) {
public router: Router, public overlayContainer: OverlayContainer, private elementRef: ElementRef,
private appRef: ApplicationRef, private ngZone: NgZone) {
this.installDiagnosticsProbe();
this.navigator = localStorage.getItem('player_navigator');
// runs on navigate, captures the route that navigated to the player (if needed)
@ -69,6 +73,36 @@ export class AppComponent implements OnInit, AfterViewInit {
}
private installDiagnosticsProbe(): void {
const diag = getYdmDiag();
diag.flags['probeInstalled'] = true;
const appRefAny = this.appRef as any;
if (!appRefAny.__ydmTickPatched) {
const originalTick = this.appRef.tick.bind(this.appRef);
appRefAny.__ydmTickPatched = true;
this.appRef.tick = (() => {
diagCount('appRef.tick');
return originalTick();
}) as typeof this.appRef.tick;
diagEvent('probe.appRef.tick.patched');
}
if (diag.flags['zoneSubscriptionsInstalled']) {
return;
}
diag.flags['zoneSubscriptionsInstalled'] = true;
this.ngZone.onUnstable.subscribe(() => diagCount('ngZone.onUnstable'));
this.ngZone.onMicrotaskEmpty.subscribe(() => diagCount('ngZone.onMicrotaskEmpty'));
this.ngZone.onStable.subscribe(() => diagCount('ngZone.onStable'));
this.ngZone.onError.subscribe(err => {
diagCount('ngZone.onError');
diagEvent('ngZone.onError', { message: err?.message ?? String(err) }, true);
});
diagEvent('probe.zone.subscriptions.installed');
}
ngOnInit(): void {
if (localStorage.getItem('theme')) {
this.setTheme(localStorage.getItem('theme'));

@ -0,0 +1,96 @@
import { NgZone } from '@angular/core';
type DiagEvent = {
ts: string;
name: string;
inZone: boolean;
data?: unknown;
};
type DiagState = {
createdAt: string;
counters: Record<string, number>;
events: DiagEvent[];
flags: Record<string, boolean>;
lastHttp?: {
kind: string;
method?: string;
url?: string;
inZone: boolean;
ts: string;
};
print: () => unknown;
clear: () => void;
};
const MAX_EVENTS = 200;
function newDiagState(): DiagState {
const state: DiagState = {
createdAt: new Date().toISOString(),
counters: {},
events: [],
flags: {},
print: () => ({
createdAt: state.createdAt,
counters: { ...state.counters },
lastHttp: state.lastHttp,
recentEvents: state.events.slice(-20)
}),
clear: () => {
state.counters = {};
state.events = [];
state.lastHttp = undefined;
}
};
return state;
}
export function getYdmDiag(): DiagState {
const g = globalThis as any;
if (!g.__ydmDiag) {
g.__ydmDiag = newDiagState();
}
return g.__ydmDiag as DiagState;
}
export function diagCount(name: string, amount = 1): number {
const diag = getYdmDiag();
diag.counters[name] = (diag.counters[name] ?? 0) + amount;
return diag.counters[name];
}
export function diagEvent(name: string, data?: unknown, printToConsole = false): void {
const diag = getYdmDiag();
const entry: DiagEvent = {
ts: new Date().toISOString(),
name,
inZone: NgZone.isInAngularZone(),
data
};
diag.events.push(entry);
if (diag.events.length > MAX_EVENTS) {
diag.events.splice(0, diag.events.length - MAX_EVENTS);
}
if (printToConsole) {
console.log('[YDM-DIAG]', entry.name, entry);
}
}
export function diagHttpCallback(kind: 'next' | 'error' | 'complete', method?: string, url?: string): void {
const inZone = NgZone.isInAngularZone();
diagCount(`http.${kind}`);
diagCount(`http.inZone.${inZone ? 'true' : 'false'}`);
const diag = getYdmDiag();
diag.lastHttp = {
kind,
method,
url,
inZone,
ts: new Date().toISOString()
};
}

@ -3,13 +3,19 @@ import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { catchError, tap } from 'rxjs/operators';
import { diagEvent, diagHttpCallback } from './diagnostics';
export const h401InterceptorFn: HttpInterceptorFn = (request, next) => {
const router = inject(Router);
const snackBar = inject(MatSnackBar);
return next(request).pipe(
tap({
next: () => diagHttpCallback('next', request.method, request.urlWithParams),
error: () => diagHttpCallback('error', request.method, request.urlWithParams),
complete: () => diagHttpCallback('complete', request.method, request.urlWithParams)
}),
catchError((err: HttpErrorResponse) => {
if (err.status === 401) {
localStorage.setItem('jwt_token', null);
@ -20,6 +26,12 @@ export const h401InterceptorFn: HttpInterceptorFn = (request, next) => {
}
}
diagEvent('http.401Interceptor.catchError', {
method: request.method,
url: request.urlWithParams,
status: err.status
});
const error = err?.error?.message || err?.statusText || 'Request failed';
return throwError(error);
})

@ -10,6 +10,7 @@ import { TwitchChatComponent } from 'app/components/twitch-chat/twitch-chat.comp
import { VideoInfoDialogComponent } from 'app/dialogs/video-info-dialog/video-info-dialog.component';
import { saveAs } from 'file-saver';
import { filter, take } from 'rxjs/operators';
import { diagEvent } from 'app/diagnostics';
export interface IMedia {
title: string;
@ -153,7 +154,15 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.uids = [this.db_file['uid']];
this.type = this.db_file['isAudio'] ? 'audio' as FileType : 'video' as FileType;
this.parseFileNames();
diagEvent('player.getFile.success', {
uid: this.uid,
fileUid: this.db_file?.uid,
show_player: this.show_player,
playlistLength: this.playlist?.length ?? 0,
currentSrcSet: !!this.currentItem?.src
}, true);
}, err => {
diagEvent('player.getFile.error', { uid: this.uid, err: String(err) }, true);
console.error(err);
this.postsService.openSnackBar($localize`Failed to get file information from the server.`, 'Dismiss');
});

@ -15,6 +15,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { Category, DBInfoResponse } from 'api-types';
import { GenerateRssUrlComponent } from 'app/dialogs/generate-rss-url/generate-rss-url.component';
import { filter, take } from 'rxjs/operators';
import { diagEvent } from 'app/diagnostics';
type CookiesTestResponse = {
success: boolean;
@ -112,8 +113,13 @@ export class SettingsComponent implements OnInit {
// sets new config as old config
this.initial_config = JSON.parse(JSON.stringify(this.new_config));
this.postsService.reload_config.next(true);
diagEvent('settings.save.success', {
settingsSame: this.settingsSame(),
multiUserMode: this.new_config?.Advanced?.multi_user_mode
}, true);
}
}, () => {
diagEvent('settings.save.error', {}, true);
console.error('Failed to save config!');
})
}

@ -6,11 +6,14 @@ import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { environment } from './environments/environment';
import { loadTranslations } from '@angular/localize';
import { getYdmDiag } from './app/diagnostics';
if (environment.production) {
enableProdMode();
}
getYdmDiag();
const locale = localStorage.getItem('locale');
if (!locale) {
localStorage.setItem('locale', 'en');

Loading…
Cancel
Save