diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 73aa083..05f9406 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -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')); diff --git a/src/app/diagnostics.ts b/src/app/diagnostics.ts new file mode 100644 index 0000000..f710706 --- /dev/null +++ b/src/app/diagnostics.ts @@ -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; + events: DiagEvent[]; + flags: Record; + 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() + }; +} + diff --git a/src/app/http.interceptor.ts b/src/app/http.interceptor.ts index 88b086c..dd12982 100644 --- a/src/app/http.interceptor.ts +++ b/src/app/http.interceptor.ts @@ -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); }) diff --git a/src/app/player/player.component.ts b/src/app/player/player.component.ts index 76d4176..df44a06 100644 --- a/src/app/player/player.component.ts +++ b/src/app/player/player.component.ts @@ -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'); }); diff --git a/src/app/settings/settings.component.ts b/src/app/settings/settings.component.ts index fd9ab0c..42fccb4 100644 --- a/src/app/settings/settings.component.ts +++ b/src/app/settings/settings.component.ts @@ -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!'); }) } diff --git a/src/main.ts b/src/main.ts index cabf9e8..c79f956 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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');