From 456bbcc0150c521d51fff1b5ad133ce8bd893894 Mon Sep 17 00:00:00 2001 From: Gui Seek Date: Mon, 9 Dec 2024 14:01:22 -0300 Subject: [PATCH] =?UTF-8?q?refactor:=20melhoria=20na=20implementa=C3=A7?= =?UTF-8?q?=C3=A3o=20de=20page=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closed #96 --- apps/devmx/src/app/app.component.ts | 26 +++++- apps/devmx/src/main.ts | 13 +-- .../contributors/contributors.component.ts | 32 ++++++++ .../feature-shell/src/lib/components/index.ts | 1 + .../lib/containers/home/home.container.html | 45 ++--------- .../src/lib/containers/home/home.container.ts | 8 +- .../api-interfaces/src/client/envs/env.ts | 4 + packages/shared/ui-global/analytics/README.md | 3 + .../ui-global/analytics/ng-package.json | 5 ++ .../shared/ui-global/analytics/src/index.ts | 3 + .../analytics/src/lib/analytics.service.ts | 81 +++++++++++++++++++ .../analytics/src/lib/error-report-handler.ts | 21 +++++ .../analytics/src/lib/utils/format-error.ts | 34 ++++++++ .../analytics/src/lib/utils/index.ts | 1 + tsconfig.base.json | 3 + 15 files changed, 224 insertions(+), 56 deletions(-) create mode 100644 packages/account/feature-shell/src/lib/components/contributors/contributors.component.ts create mode 100644 packages/shared/ui-global/analytics/README.md create mode 100644 packages/shared/ui-global/analytics/ng-package.json create mode 100644 packages/shared/ui-global/analytics/src/index.ts create mode 100644 packages/shared/ui-global/analytics/src/lib/analytics.service.ts create mode 100644 packages/shared/ui-global/analytics/src/lib/error-report-handler.ts create mode 100644 packages/shared/ui-global/analytics/src/lib/utils/format-error.ts create mode 100644 packages/shared/ui-global/analytics/src/lib/utils/index.ts diff --git a/apps/devmx/src/app/app.component.ts b/apps/devmx/src/app/app.component.ts index df0a18a7..6228e8d4 100644 --- a/apps/devmx/src/app/app.component.ts +++ b/apps/devmx/src/app/app.component.ts @@ -1,4 +1,8 @@ -import { RouterOutlet } from '@angular/router'; +import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { AnalyticsService } from '@devmx/shared-ui-global/analytics'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Env } from '@devmx/shared-api-interfaces/client'; +import { filter, map, pairwise, startWith } from 'rxjs'; import { Component } from '@angular/core'; @Component({ @@ -13,4 +17,22 @@ import { Component } from '@angular/core'; `, imports: [RouterOutlet], }) -export class AppComponent {} +export class AppComponent { + constructor(env: Env, analyticsService: AnalyticsService, router: Router) { + const routes$ = router.events.pipe( + filter((event) => event instanceof NavigationEnd), + map((e) => e.urlAfterRedirects), + startWith(''), + pairwise() + ); + + routes$ + .pipe( + filter(() => env.prod), + takeUntilDestroyed() + ) + .subscribe(([, toUrl]) => { + analyticsService.locationChanged(toUrl); + }); + } +} diff --git a/apps/devmx/src/main.ts b/apps/devmx/src/main.ts index 63b60451..14a76203 100644 --- a/apps/devmx/src/main.ts +++ b/apps/devmx/src/main.ts @@ -1,15 +1,6 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { AppComponent } from './app/app.component'; import { appConfig } from './app/app.config'; -import { env } from './envs/env'; -import './app/utils/google-tag'; -if (env.prod) { - document.body.appendChild( - document.createElement('script', { is: 'google-tag' }) - ); -} - -bootstrapApplication(AppComponent, appConfig).catch((err) => - console.error(err) -); +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/packages/account/feature-shell/src/lib/components/contributors/contributors.component.ts b/packages/account/feature-shell/src/lib/components/contributors/contributors.component.ts new file mode 100644 index 00000000..1e0ef2d0 --- /dev/null +++ b/packages/account/feature-shell/src/lib/components/contributors/contributors.component.ts @@ -0,0 +1,32 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { GithubContributor } from '@devmx/shared-api-interfaces'; +import { MatChip, MatChipAvatar } from '@angular/material/chips'; + +@Component({ + selector: 'devmx-contributors', + template: ` + + @for (contributor of data(); track contributor.id) { + + + {{ contributor.login }} + + } + + `, + styles: ` + :host { + display: flex; + flex-direction: column; + + mat-chip { + margin-right: 1em; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatChip, MatChipAvatar], +}) +export class ContributorsComponent { + data = input([]); +} diff --git a/packages/account/feature-shell/src/lib/components/index.ts b/packages/account/feature-shell/src/lib/components/index.ts index 5eda18b1..6c881e5f 100644 --- a/packages/account/feature-shell/src/lib/components/index.ts +++ b/packages/account/feature-shell/src/lib/components/index.ts @@ -3,4 +3,5 @@ export * from './contributor-card-list/contributor-card-list.component'; export * from './album-card-list/album-card-list.component'; export * from './editable-photo/editable-photo.component'; export * from './editable-roles/editable-roles.component'; +export * from './contributors/contributors.component'; export * from './social-icon/social-icon.component'; diff --git a/packages/account/feature-shell/src/lib/containers/home/home.container.html b/packages/account/feature-shell/src/lib/containers/home/home.container.html index 1ed093f4..f8b509b1 100644 --- a/packages/account/feature-shell/src/lib/containers/home/home.container.html +++ b/packages/account/feature-shell/src/lib/containers/home/home.container.html @@ -1,29 +1,4 @@ - +
@@ -60,7 +35,9 @@ } @placeholder { } +
+
@defer (on timer(500ms)) { @@ -73,16 +50,8 @@ }
- -
- @defer (on timer(500ms)) { - - @if (githubFacade.contributors$ | async; as contributors) { - - } - - } @placeholder { - - } -
+ +@if (githubFacade.contributors$ | async; as contributors) { + +} diff --git a/packages/account/feature-shell/src/lib/containers/home/home.container.ts b/packages/account/feature-shell/src/lib/containers/home/home.container.ts index ea3bb249..1777dab6 100644 --- a/packages/account/feature-shell/src/lib/containers/home/home.container.ts +++ b/packages/account/feature-shell/src/lib/containers/home/home.container.ts @@ -4,7 +4,6 @@ import { PresentationFacade } from '@devmx/presentation-data-access'; import { SkeletonComponent } from '@devmx/shared-ui-global/skeleton'; import { EventCardListComponent } from '@devmx/event-ui-shared'; import { JobOpeningFacade } from '@devmx/career-data-access'; -import { IconComponent } from '@devmx/shared-ui-global/icon'; import { MatButtonModule } from '@angular/material/button'; import { GithubFacade } from '@devmx/shared-data-access'; import { MatCardModule } from '@angular/material/card'; @@ -14,7 +13,7 @@ import { AsyncPipe } from '@angular/common'; import { AlbumCardListComponent, JobOpeningCardListComponent, - ContributorCardListComponent, + ContributorsComponent, } from '../../components'; @Component({ selector: 'devmx-home', @@ -23,14 +22,13 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ PresentationCardListComponent, - ContributorCardListComponent, JobOpeningCardListComponent, EventCardListComponent, AlbumCardListComponent, + ContributorsComponent, SkeletonComponent, - MatCardModule, MatButtonModule, - IconComponent, + MatCardModule, AsyncPipe, ], standalone: true, diff --git a/packages/shared/api-interfaces/src/client/envs/env.ts b/packages/shared/api-interfaces/src/client/envs/env.ts index ec6f5c03..bbe23566 100644 --- a/packages/shared/api-interfaces/src/client/envs/env.ts +++ b/packages/shared/api-interfaces/src/client/envs/env.ts @@ -1,4 +1,6 @@ export abstract class Env { + abstract prod: boolean + abstract api: { url: string; }; @@ -12,4 +14,6 @@ export abstract class Env { url: string; }; }; + + abstract googleTag: string } diff --git a/packages/shared/ui-global/analytics/README.md b/packages/shared/ui-global/analytics/README.md new file mode 100644 index 00000000..df550b13 --- /dev/null +++ b/packages/shared/ui-global/analytics/README.md @@ -0,0 +1,3 @@ +# @devmx/shared-ui-global/analytics + +Secondary entry point of `@devmx/shared-ui-global`. It can be used by importing from `@devmx/shared-ui-global/analytics`. diff --git a/packages/shared/ui-global/analytics/ng-package.json b/packages/shared/ui-global/analytics/ng-package.json new file mode 100644 index 00000000..c781f0df --- /dev/null +++ b/packages/shared/ui-global/analytics/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/packages/shared/ui-global/analytics/src/index.ts b/packages/shared/ui-global/analytics/src/index.ts new file mode 100644 index 00000000..eb504260 --- /dev/null +++ b/packages/shared/ui-global/analytics/src/index.ts @@ -0,0 +1,3 @@ + +export * from './lib/error-report-handler' +export * from './lib/analytics.service' diff --git a/packages/shared/ui-global/analytics/src/lib/analytics.service.ts b/packages/shared/ui-global/analytics/src/lib/analytics.service.ts new file mode 100644 index 00000000..8db84162 --- /dev/null +++ b/packages/shared/ui-global/analytics/src/lib/analytics.service.ts @@ -0,0 +1,81 @@ +import { Env } from '@devmx/shared-api-interfaces/client'; +import { formatErrorEventForAnalytics } from './utils'; +import { Injectable } from '@angular/core'; + +declare global { + interface Window { + dataLayer?: unknown[]; + gtag?(...args: unknown[]): void; + } +} + +@Injectable({ providedIn: 'root' }) +export class AnalyticsService { + private previousUrl: string | undefined; + + constructor(private env: Env) { + if (env.prod) { + this.#installGlobalSiteTag(); + this.#installWindowErrorHandler(); + } + } + + reportError(description: string, fatal = true) { + // Limit descriptions to maximum of 150 characters. + // See: https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#exd. + description = description.substring(0, 150); + + this.#gtag('event', 'exception', { description: description, fatal }); + } + + locationChanged(url: string) { + this.#sendPage(url); + } + + #sendPage(url: string) { + // Won't re-send if the url hasn't changed. + if (url === this.previousUrl) { + return; + } + this.previousUrl = url; + } + + #gtag(...args: unknown[]) { + if (window.gtag) { + window.gtag(...args); + } + } + + #installGlobalSiteTag() { + const url = `https://www.googletagmanager.com/gtag/js?id=${this.env.googleTag}`; + + // Note: This cannot be an arrow function as `gtag.js` expects an actual `Arguments` + // instance with e.g. `callee` to be set. Do not attempt to change this and keep this + // as much as possible in sync with the tracking code snippet suggested by the Google + // Analytics 4 web UI under `Data Streams`. + window.dataLayer = window.dataLayer || []; + window.gtag = function (...params: unknown[]) { + window.dataLayer?.push(params); + }; + window.gtag('js', new Date()); + + // Configure properties before loading the script. This is necessary to avoid + // loading multiple instances of the gtag JS scripts. + window.gtag('config', this.env.googleTag); + + if (!this.env.prod) { + return; + } + + const el = window.document.createElement('script'); + el.async = true; + el.src = url; + window.document.head.appendChild(el); + } + + #installWindowErrorHandler() { + window.addEventListener('error', (event) => + this.reportError(formatErrorEventForAnalytics(event), true) + ); + } +} diff --git a/packages/shared/ui-global/analytics/src/lib/error-report-handler.ts b/packages/shared/ui-global/analytics/src/lib/error-report-handler.ts new file mode 100644 index 00000000..8c257e12 --- /dev/null +++ b/packages/shared/ui-global/analytics/src/lib/error-report-handler.ts @@ -0,0 +1,21 @@ +import { ErrorHandler, Injectable } from '@angular/core'; +import { AnalyticsService } from './analytics.service'; +import { formatErrorForAnalytics } from './utils'; + +@Injectable() +export class AnalyticsErrorReportHandler extends ErrorHandler { + constructor(private _analytics: AnalyticsService) { + super(); + } + + override handleError(error: ErrorHandler) { + super.handleError(error); + + // Report the error in Google Analytics. + if (error instanceof Error) { + this._analytics.reportError(formatErrorForAnalytics(error)); + } else { + this._analytics.reportError(error.toString()); + } + } +} diff --git a/packages/shared/ui-global/analytics/src/lib/utils/format-error.ts b/packages/shared/ui-global/analytics/src/lib/utils/format-error.ts new file mode 100644 index 00000000..3ea80a08 --- /dev/null +++ b/packages/shared/ui-global/analytics/src/lib/utils/format-error.ts @@ -0,0 +1,34 @@ +export function formatErrorEventForAnalytics(event: ErrorEvent): string { + const { message, filename, colno, lineno, error } = event; + + if (error instanceof Error) { + return formatErrorForAnalytics(error); + } + + const info = `${filename}:${lineno || '?'}:${colno || '?'}`; + return `${stripErrorMessagePrefix(message)} \n ${info}`; +} + +export function formatErrorForAnalytics(error: Error): string { + let stack = ''; + + if (error.stack) { + stack = stripErrorMessagePrefix(error.stack) + // strip the message from the stack trace, if present + .replace(error.message + '\n', '') + // strip leading spaces + .replace(/^ +/gm, '') + // strip all leading "at " for each frame + .replace(/^at /gm, '') + // replace long urls with just the last segment: `filename:line:column` + .replace(/(?: \(|@)http.+\/([^/)]+)\)?(?:\n|$)/gm, '@$1\n') + // replace "eval code" in Edge + .replace(/ *\(eval code(:\d+:\d+)\)(?:\n|$)/gm, '@???$1\n'); + } + + return `${error.message}\n${stack}`; +} + +function stripErrorMessagePrefix(input: string): string { + return input.replace(/^(Uncaught )?Error: /, ''); +} diff --git a/packages/shared/ui-global/analytics/src/lib/utils/index.ts b/packages/shared/ui-global/analytics/src/lib/utils/index.ts new file mode 100644 index 00000000..e0835d8b --- /dev/null +++ b/packages/shared/ui-global/analytics/src/lib/utils/index.ts @@ -0,0 +1 @@ +export * from './format-error'; diff --git a/tsconfig.base.json b/tsconfig.base.json index 91076771..91bc0021 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -170,6 +170,9 @@ "@devmx/shared-data-source": ["packages/shared/data-source/src/index.ts"], "@devmx/shared-resource": ["packages/shared/resource/src/index.ts"], "@devmx/shared-ui-global": ["packages/shared/ui-global/src/index.ts"], + "@devmx/shared-ui-global/analytics": [ + "packages/shared/ui-global/analytics/src/index.ts" + ], "@devmx/shared-ui-global/bash": [ "packages/shared/ui-global/bash/src/index.ts" ],