From d1ebcca467c8cc87a010fb9fff959885a16391e1 Mon Sep 17 00:00:00 2001 From: Kilian Panot Date: Mon, 27 May 2024 15:09:36 +0900 Subject: [PATCH] feat: add service support to analytics mechnism --- .../src/style/dark-theme/dark-theme.scss | 110 ++--- .../style/horizon-theme/horizon-theme.scss | 92 ++-- apps/showcase/src/style/theme.scss | 102 ++-- docs/analytics/ANALYTICS.md | 19 +- docs/analytics/PERFORMANCE.md | 176 +++++++ docs/analytics/TRACK_EVENTS(deprecated).md | 118 +++++ docs/analytics/TRACK_EVENTS.md | 464 ++++++++++-------- packages/@o3r/analytics/README.md | 2 +- packages/@o3r/analytics/package.json | 5 + .../templates/__name__.analytics.ts.template | 2 +- .../event-track.service.fixture.jasmine.ts | 15 +- .../contracts/events-contracts.ts | 0 .../src/{ => performance}/contracts/index.ts | 0 .../src/{ => performance}/directives/index.ts | 0 .../track-events/base-track-events.ts | 0 .../directives/track-events/index.ts | 0 .../track-click/track-click.directive.ts | 1 + .../track-click/track-click.spec.ts | 0 .../track-events/track-events.directive.ts | 1 + .../track-events/track-events.module.ts | 0 .../track-events/track-events.spec.ts | 0 .../track-focus/track-focus.directive.ts | 1 + .../track-focus/track-focus.spec.ts | 0 .../@o3r/analytics/src/performance/index.ts | 4 + .../event-track/event-track.configuration.ts | 12 +- .../event-track/event-track.service.spec.ts | 0 .../event-track/event-track.service.ts | 17 +- .../services/event-track/index.ts | 0 .../src/{ => performance}/services/index.ts | 0 .../stores/event-track/event-track.actions.ts | 0 .../stores/event-track/event-track.module.ts | 0 .../event-track/event-track.reducer.spec.ts | 0 .../stores/event-track/event-track.reducer.ts | 0 .../event-track/event-track.selectors.spec.ts | 0 .../event-track/event-track.selectors.ts | 0 .../stores/event-track/event-track.state.ts | 0 .../stores/event-track/event-track.sync.ts | 0 .../stores/event-track/index.ts | 0 .../src/{ => performance}/stores/index.ts | 0 packages/@o3r/analytics/src/public_api.ts | 7 +- .../directives/click-event.directive.ts | 30 ++ .../tracker/directives/events.directive.ts | 97 ++++ .../directives/focus-event.directive.ts | 28 ++ .../directives/generic-event.directive.ts | 74 +++ .../analytics/src/tracker/directives/index.ts | 3 + .../src/tracker/events/base.interface.ts | 75 +++ .../analytics/src/tracker/events/index.ts | 1 + packages/@o3r/analytics/src/tracker/index.ts | 5 + .../analytics/src/tracker/logger/index.ts | 1 + .../tracker/logger/logger.analytics.spec.ts | 43 ++ .../src/tracker/logger/logger.analytics.ts | 74 +++ .../services/analytics-tracker.module.ts | 37 ++ .../analytics/src/tracker/services/index.ts | 3 + .../analytics-router.service.ts | 33 ++ .../analytics-router.servioce.spec.ts | 79 +++ .../tracker/services/router-tracker/index.ts | 1 + .../analytics-reporter.configuration.ts | 21 + .../tracker/analytics-reporter.service.ts | 100 ++++ .../src/tracker/services/tracker/index.ts | 2 + .../analytics-third-party.interfaces.ts | 20 + .../google-analytics.analytics.spec.ts | 88 ++++ .../google-analytics.analytics.ts | 88 ++++ .../src/tracker/third-party/index.ts | 2 + packages/@o3r/logger/README.md | 2 +- .../src/services/logger/logger.console.ts | 2 +- yarn.lock | 4 + 66 files changed, 1682 insertions(+), 379 deletions(-) create mode 100644 docs/analytics/PERFORMANCE.md create mode 100644 docs/analytics/TRACK_EVENTS(deprecated).md rename packages/@o3r/analytics/src/{ => performance}/contracts/events-contracts.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/contracts/index.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/directives/index.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/directives/track-events/base-track-events.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/directives/track-events/index.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/directives/track-events/track-click/track-click.directive.ts (94%) rename packages/@o3r/analytics/src/{ => performance}/directives/track-events/track-click/track-click.spec.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/directives/track-events/track-events.directive.ts (95%) rename packages/@o3r/analytics/src/{ => performance}/directives/track-events/track-events.module.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/directives/track-events/track-events.spec.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/directives/track-events/track-focus/track-focus.directive.ts (94%) rename packages/@o3r/analytics/src/{ => performance}/directives/track-events/track-focus/track-focus.spec.ts (100%) create mode 100644 packages/@o3r/analytics/src/performance/index.ts rename packages/@o3r/analytics/src/{ => performance}/services/event-track/event-track.configuration.ts (70%) rename packages/@o3r/analytics/src/{ => performance}/services/event-track/event-track.service.spec.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/services/event-track/event-track.service.ts (95%) rename packages/@o3r/analytics/src/{ => performance}/services/event-track/index.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/services/index.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/stores/event-track/event-track.actions.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/stores/event-track/event-track.module.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/stores/event-track/event-track.reducer.spec.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/stores/event-track/event-track.reducer.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/stores/event-track/event-track.selectors.spec.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/stores/event-track/event-track.selectors.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/stores/event-track/event-track.state.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/stores/event-track/event-track.sync.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/stores/event-track/index.ts (100%) rename packages/@o3r/analytics/src/{ => performance}/stores/index.ts (100%) create mode 100644 packages/@o3r/analytics/src/tracker/directives/click-event.directive.ts create mode 100644 packages/@o3r/analytics/src/tracker/directives/events.directive.ts create mode 100644 packages/@o3r/analytics/src/tracker/directives/focus-event.directive.ts create mode 100644 packages/@o3r/analytics/src/tracker/directives/generic-event.directive.ts create mode 100644 packages/@o3r/analytics/src/tracker/directives/index.ts create mode 100644 packages/@o3r/analytics/src/tracker/events/base.interface.ts create mode 100644 packages/@o3r/analytics/src/tracker/events/index.ts create mode 100644 packages/@o3r/analytics/src/tracker/index.ts create mode 100644 packages/@o3r/analytics/src/tracker/logger/index.ts create mode 100644 packages/@o3r/analytics/src/tracker/logger/logger.analytics.spec.ts create mode 100644 packages/@o3r/analytics/src/tracker/logger/logger.analytics.ts create mode 100644 packages/@o3r/analytics/src/tracker/services/analytics-tracker.module.ts create mode 100644 packages/@o3r/analytics/src/tracker/services/index.ts create mode 100644 packages/@o3r/analytics/src/tracker/services/router-tracker/analytics-router.service.ts create mode 100644 packages/@o3r/analytics/src/tracker/services/router-tracker/analytics-router.servioce.spec.ts create mode 100644 packages/@o3r/analytics/src/tracker/services/router-tracker/index.ts create mode 100644 packages/@o3r/analytics/src/tracker/services/tracker/analytics-reporter.configuration.ts create mode 100644 packages/@o3r/analytics/src/tracker/services/tracker/analytics-reporter.service.ts create mode 100644 packages/@o3r/analytics/src/tracker/services/tracker/index.ts create mode 100644 packages/@o3r/analytics/src/tracker/third-party/analytics-third-party.interfaces.ts create mode 100644 packages/@o3r/analytics/src/tracker/third-party/google-analytics/google-analytics.analytics.spec.ts create mode 100644 packages/@o3r/analytics/src/tracker/third-party/google-analytics/google-analytics.analytics.ts create mode 100644 packages/@o3r/analytics/src/tracker/third-party/index.ts diff --git a/apps/showcase/src/style/dark-theme/dark-theme.scss b/apps/showcase/src/style/dark-theme/dark-theme.scss index c26ebe206a..9b56786801 100644 --- a/apps/showcase/src/style/dark-theme/dark-theme.scss +++ b/apps/showcase/src/style/dark-theme/dark-theme.scss @@ -2,100 +2,100 @@ :root { /* --- BEGIN THEME Auto-generated --- */ +--bs-body-bg: #000000; +--bs-body-color: #ffffff; +.card { --bs-card-bg: #000000; } +.card { --bs-card-color: #ffffff; } +.nav-pills { --bs-nav-pills-link-active-color: var(--color-primary); } +.navbar-toggler { --bs-navbar-color: var(--bs-primary-bg-subtle); } /* Application Primary color */ --bs-primary: var(--color-primary); --bs-primary-800: var(--color-primary-800); -.nav-pills { --bs-nav-pills-link-active-color: var(--color-primary); } -.navbar-toggler { --bs-navbar-color: var(--bs-primary-bg-subtle); } ---color-primary-50: #e6edfc; ---color-primary-100: #c2d3f7; ---color-primary-200: #99b6f2; ---color-primary-300: #7098ed; ---color-primary-400: #5182e9; ---color-primary-500: #326ce5; ---color-primary-600: #2d64e2; ---color-primary-700: #2659de; ---color-primary-800: #1f4fda; ---color-primary-900: #133dd3; ---color-primary-A700: #839aff; ---color-primary-A400: #9caeff; ---color-primary-A200: #cfd8ff; ---color-primary-A100: #000000; ---color-primary: var(--color-primary-500); ---color-accent-50: #e8f7ff; +--bs-tertiary-bg: #333333; +--bs-tertiary-bg-blue: 51; +--bs-tertiary-bg-green: 51; +--bs-tertiary-bg-red: 51; +--bs-tertiary-bg-rgb: var(--bs-tertiary-bg-red), var(--bs-tertiary-bg-green), var(--bs-tertiary-bg-blue); +--color-accent: var(--color-accent-500); --color-accent-100: #c5eaff; --color-accent-200: #9eddff; --color-accent-300: #77cfff; --color-accent-400: #5ac4ff; +--color-accent-50: #e8f7ff; --color-accent-500: #3dbaff; --color-accent-600: #37b3ff; --color-accent-700: #2fabff; --color-accent-800: #27a3ff; --color-accent-900: #1a94ff; ---color-accent-A700: #b2d8ff; ---color-accent-A400: #cbe5ff; ---color-accent-A200: #feffff; --color-accent-A100: #ffffff; ---color-accent: var(--color-accent-500); ---color-highlight-50: #f4e5ff; +--color-accent-A200: #feffff; +--color-accent-A400: #cbe5ff; +--color-accent-A700: #b2d8ff; +--color-highlight: var(--color-highlight-500); --color-highlight-100: #e4beff; --color-highlight-200: #d292ff; --color-highlight-300: #c066ff; --color-highlight-400: #b346ff; +--color-highlight-50: #f4e5ff; --color-highlight-500: #a525ff; --color-highlight-600: #9d21ff; --color-highlight-700: #931bff; --color-highlight-800: #8a16ff; --color-highlight-900: #790dff; ---color-highlight-A700: #ccaaff; ---color-highlight-A400: #dcc3ff; ---color-highlight-A200: #faf6ff; --color-highlight-A100: #ffffff; ---color-highlight: var(--color-highlight-500); ---color-warn-50: #fdede4; +--color-highlight-A200: #faf6ff; +--color-highlight-A400: #dcc3ff; +--color-highlight-A700: #ccaaff; +--color-neutral-alabaster: #f5f5f7; +--color-neutral-black: #000000; +--color-neutral-gray-dark: #616161; +--color-neutral-gray-light: #eff5f9; +--color-neutral-iron: #dbdff8; +--color-neutral-silver: #f8f8f8; +--color-neutral-white: #ffffff; +--color-out-of-palette-comet: #1b2073; +--color-out-of-palette-green: #067f28; +--color-primary: var(--color-primary-500); +--color-primary-100: #c2d3f7; +--color-primary-200: #99b6f2; +--color-primary-300: #7098ed; +--color-primary-400: #5182e9; +--color-primary-50: #e6edfc; +--color-primary-500: #326ce5; +--color-primary-600: #2d64e2; +--color-primary-700: #2659de; +--color-primary-800: #1f4fda; +--color-primary-900: #133dd3; +--color-primary-A100: #000000; +--color-primary-A200: #cfd8ff; +--color-primary-A400: #9caeff; +--color-primary-A700: #839aff; +--color-warn: var(--color-warn-500); --color-warn-100: #fbd1bb; --color-warn-200: #f8b38e; --color-warn-300: #f59460; --color-warn-400: #f27d3e; +--color-warn-50: #fdede4; --color-warn-500: #f0661c; --color-warn-600: #ee5e19; --color-warn-700: #ec5314; --color-warn-800: #e94911; --color-warn-900: #dc3709; ---color-warn-A700: #ffa28f; ---color-warn-A400: #ffb7a8; ---color-warn-A200: #ffe1db; --color-warn-A100: #ffffff; ---color-warn: var(--color-warn-500); ---color-out-of-palette-green: #067f28; ---color-out-of-palette-comet: #1b2073; ---color-neutral-iron: #dbdff8; ---color-neutral-alabaster: #f5f5f7; ---color-neutral-white: #ffffff; ---color-neutral-silver: #f8f8f8; ---color-neutral-gray-light: #eff5f9; ---color-neutral-black: #000000; ---color-neutral-gray-dark: #616161; +--color-warn-A200: #ffe1db; +--color-warn-A400: #ffb7a8; +--color-warn-A700: #ffa28f; +--radius-10: 10px; +--radius-2: 2px; +--radius-25: 25px; +--radius-5: 5px; --spacing-0: 0px; --spacing-1: 1px; ---spacing-5: 5px; --spacing-10: 10px; --spacing-20: 20px; --spacing-30: 30px; --spacing-40: 40px; ---radius-2: 2px; ---radius-5: 5px; ---radius-10: 10px; ---radius-25: 25px; ---bs-body-bg: #000000; ---bs-body-color: #ffffff; ---bs-tertiary-bg: #333333; ---bs-tertiary-bg-red: 51; ---bs-tertiary-bg-green: 51; ---bs-tertiary-bg-blue: 51; ---bs-tertiary-bg-rgb: var(--bs-tertiary-bg-red), var(--bs-tertiary-bg-green), var(--bs-tertiary-bg-blue); -.card { --bs-card-color: #ffffff; } -.card { --bs-card-bg: #000000; } +--spacing-5: 5px; /* --- END THEME Auto-generated --- */ .nav.nav-pills .nav-link { diff --git a/apps/showcase/src/style/horizon-theme/horizon-theme.scss b/apps/showcase/src/style/horizon-theme/horizon-theme.scss index 49c91c62c7..7bef32f0bf 100644 --- a/apps/showcase/src/style/horizon-theme/horizon-theme.scss +++ b/apps/showcase/src/style/horizon-theme/horizon-theme.scss @@ -1,90 +1,90 @@ :root { /* --- BEGIN THEME Auto-generated --- */ +.nav-pills { --bs-nav-pills-link-active-color: var(--color-primary); } +.navbar-toggler { --bs-navbar-color: var(--bs-primary-bg-subtle); } /* Application Primary color */ --bs-primary: var(--color-primary); --bs-primary-800: var(--color-primary-800); -.nav-pills { --bs-nav-pills-link-active-color: var(--color-primary); } -.navbar-toggler { --bs-navbar-color: var(--bs-primary-bg-subtle); } ---color-primary-50: #f0e4ea; ---color-primary-100: #d8bbca; ---color-primary-200: #bf8da6; ---color-primary-300: #a55f82; ---color-primary-400: #913d68; ---color-primary-500: #7e1b4d; ---color-primary-600: #761846; ---color-primary-700: #6b143d; ---color-primary-800: #611034; ---color-primary-900: #4e0825; ---color-primary-A700: #ff035b; ---color-primary-A400: #ff1d6c; ---color-primary-A200: #ff508d; ---color-primary-A100: #ff83ae; ---color-primary: var(--color-primary-500); ---color-accent-50: #fdf8e6; +--color-accent: var(--color-accent-500); --color-accent-100: #fbedc2; --color-accent-200: #f8e199; --color-accent-300: #f5d470; --color-accent-400: #f3cb51; +--color-accent-50: #fdf8e6; --color-accent-500: #f1c232; --color-accent-600: #efbc2d; --color-accent-700: #edb426; --color-accent-800: #ebac1f; --color-accent-900: #e79f13; ---color-accent-A700: #ffd896; ---color-accent-A400: #ffe2af; ---color-accent-A200: #fff4e2; --color-accent-A100: #ffffff; ---color-accent: var(--color-accent-500); ---color-highlight-50: #e1e1f4; +--color-accent-A200: #fff4e2; +--color-accent-A400: #ffe2af; +--color-accent-A700: #ffd896; +--color-highlight: var(--color-highlight-500); --color-highlight-100: #b5b3e3; --color-highlight-200: #8481d1; --color-highlight-300: #524ebf; --color-highlight-400: #2d28b1; +--color-highlight-50: #e1e1f4; --color-highlight-500: #0802a3; --color-highlight-600: #07029b; --color-highlight-700: #060191; --color-highlight-800: #040188; --color-highlight-900: #020177; ---color-highlight-A700: #2525ff; ---color-highlight-A400: #3f3fff; ---color-highlight-A200: #7272ff; --color-highlight-A100: #a5a5ff; ---color-highlight: var(--color-highlight-500); ---color-warn-50: #fdede4; +--color-highlight-A200: #7272ff; +--color-highlight-A400: #3f3fff; +--color-highlight-A700: #2525ff; +--color-neutral-alabaster: #f5f5f7; +--color-neutral-black: #000000; +--color-neutral-gray-dark: #616161; +--color-neutral-gray-light: #f0f0f0; +--color-neutral-iron: #e4dbd1; +--color-neutral-silver: #f8f8f8; +--color-neutral-white: #ffffff; +--color-out-of-palette-comet: #6a5a48; +--color-out-of-palette-green: #28ad4e; +--color-primary: var(--color-primary-500); +--color-primary-100: #d8bbca; +--color-primary-200: #bf8da6; +--color-primary-300: #a55f82; +--color-primary-400: #913d68; +--color-primary-50: #f0e4ea; +--color-primary-500: #7e1b4d; +--color-primary-600: #761846; +--color-primary-700: #6b143d; +--color-primary-800: #611034; +--color-primary-900: #4e0825; +--color-primary-A100: #ff83ae; +--color-primary-A200: #ff508d; +--color-primary-A400: #ff1d6c; +--color-primary-A700: #ff035b; +--color-warn: var(--color-warn-500); --color-warn-100: #fbd1bb; --color-warn-200: #f8b38e; --color-warn-300: #f59460; --color-warn-400: #f27d3e; +--color-warn-50: #fdede4; --color-warn-500: #f0661c; --color-warn-600: #ee5e19; --color-warn-700: #ec5314; --color-warn-800: #e94911; --color-warn-900: #dc3709; ---color-warn-A700: #ffa28f; ---color-warn-A400: #ffb7a8; ---color-warn-A200: #ffe1db; --color-warn-A100: #ffffff; ---color-warn: var(--color-warn-500); ---color-out-of-palette-green: #28ad4e; ---color-out-of-palette-comet: #6a5a48; ---color-neutral-iron: #e4dbd1; ---color-neutral-alabaster: #f5f5f7; ---color-neutral-white: #ffffff; ---color-neutral-silver: #f8f8f8; ---color-neutral-gray-light: #f0f0f0; ---color-neutral-black: #000000; ---color-neutral-gray-dark: #616161; +--color-warn-A200: #ffe1db; +--color-warn-A400: #ffb7a8; +--color-warn-A700: #ffa28f; +--radius-10: 10px; +--radius-2: 2px; +--radius-25: 25px; +--radius-5: 5px; --spacing-0: 0px; --spacing-1: 1px; ---spacing-5: 5px; --spacing-10: 10px; --spacing-20: 20px; --spacing-30: 30px; --spacing-40: 40px; ---radius-2: 2px; ---radius-5: 5px; ---radius-10: 10px; ---radius-25: 25px; +--spacing-5: 5px; /* --- END THEME Auto-generated --- */ } \ No newline at end of file diff --git a/apps/showcase/src/style/theme.scss b/apps/showcase/src/style/theme.scss index 1c8a27030b..aa29c7d32f 100644 --- a/apps/showcase/src/style/theme.scss +++ b/apps/showcase/src/style/theme.scss @@ -1,95 +1,95 @@ :root { /* --- BEGIN THEME Auto-generated --- */ +.nav-pills { --bs-nav-pills-link-active-color: var(--color-primary); } +.navbar-toggler { --bs-navbar-color: var(--bs-primary-bg-subtle); } /* Application Primary color */ --bs-primary: var(--color-primary); --bs-primary-800: var(--color-primary-800); -.nav-pills { --bs-nav-pills-link-active-color: var(--color-primary); } -.navbar-toggler { --bs-navbar-color: var(--bs-primary-bg-subtle); } ---color-primary-50: #ebf3ff; ---color-primary-100: #c5d5f9; ---color-primary-200: #9fc6ff; ---color-primary-300: #61a2ff; ---color-primary-400: #3a8bff; ---color-primary-500: #0c66e1; ---color-primary-600: #104ea4; ---color-primary-700: #0a2f62; ---color-primary-800: #000835; ---color-primary-900: #000521; ---color-primary-A700: #839aff; ---color-primary-A400: #9caeff; ---color-primary-A200: #cfd8ff; ---color-primary-A100: #ffffff; -/* Primary palette */ ---color-primary: var(--color-primary-500); ---color-accent-50: #e8f7ff; +/* Accent palette */ +--color-accent: var(--color-accent-500); --color-accent-100: #c5eaff; --color-accent-200: #9eddff; --color-accent-300: #77cfff; --color-accent-400: #5ac4ff; +--color-accent-50: #e8f7ff; --color-accent-500: #3dbaff; --color-accent-600: #37b3ff; --color-accent-700: #2fabff; --color-accent-800: #27a3ff; --color-accent-900: #1a94ff; ---color-accent-A700: #b2d8ff; ---color-accent-A400: #cbe5ff; ---color-accent-A200: #feffff; --color-accent-A100: #ffffff; -/* Accent palette */ ---color-accent: var(--color-accent-500); ---color-highlight-50: #f4e5ff; +--color-accent-A200: #feffff; +--color-accent-A400: #cbe5ff; +--color-accent-A700: #b2d8ff; +/* Highlight palette */ +--color-highlight: var(--color-highlight-500); --color-highlight-100: #e4beff; --color-highlight-200: #d292ff; --color-highlight-300: #c066ff; --color-highlight-400: #b346ff; +--color-highlight-50: #f4e5ff; --color-highlight-500: #a525ff; --color-highlight-600: #9d21ff; --color-highlight-700: #931bff; --color-highlight-800: #8a16ff; --color-highlight-900: #790dff; ---color-highlight-A700: #ccaaff; ---color-highlight-A400: #dcc3ff; ---color-highlight-A200: #faf6ff; --color-highlight-A100: #ffffff; -/* Highlight palette */ ---color-highlight: var(--color-highlight-500); ---color-warn-50: #fdede4; +--color-highlight-A200: #faf6ff; +--color-highlight-A400: #dcc3ff; +--color-highlight-A700: #ccaaff; +--color-neutral-alabaster: #f5f5f7; +--color-neutral-black: #000000; +--color-neutral-custom: var(--color-primary); +--color-neutral-gray-dark: #616161; +--color-neutral-gray-light: #eff5f9; +--color-neutral-iron: #dbdff8; +--color-neutral-silver: #f8f8f8; +--color-neutral-white: #ffffff; +--color-out-of-palette-comet: #1b2073; +--color-out-of-palette-green: #067f28; +/* Primary palette */ +--color-primary: var(--color-primary-500); +--color-primary-100: #c5d5f9; +--color-primary-200: #9fc6ff; +--color-primary-300: #61a2ff; +--color-primary-400: #3a8bff; +--color-primary-50: #ebf3ff; +--color-primary-500: #0c66e1; +--color-primary-600: #104ea4; +--color-primary-700: #0a2f62; +--color-primary-800: #000835; +--color-primary-900: #000521; +--color-primary-A100: #ffffff; +--color-primary-A200: #cfd8ff; +--color-primary-A400: #9caeff; +--color-primary-A700: #839aff; +/* Warn palette */ +--color-warn: var(--color-warn-500); --color-warn-100: #fbd1bb; --color-warn-200: #f8b38e; --color-warn-300: #f59460; --color-warn-400: #f27d3e; +--color-warn-50: #fdede4; --color-warn-500: #f0661c; --color-warn-600: #ee5e19; --color-warn-700: #ec5314; --color-warn-800: #e94911; --color-warn-900: #dc3709; ---color-warn-A700: #ffa28f; ---color-warn-A400: #ffb7a8; ---color-warn-A200: #ffe1db; --color-warn-A100: #ffffff; -/* Warn palette */ ---color-warn: var(--color-warn-500); ---color-out-of-palette-green: #067f28; ---color-out-of-palette-comet: #1b2073; ---color-neutral-iron: #dbdff8; ---color-neutral-alabaster: #f5f5f7; ---color-neutral-white: #ffffff; ---color-neutral-silver: #f8f8f8; ---color-neutral-gray-light: #eff5f9; ---color-neutral-black: #000000; ---color-neutral-gray-dark: #616161; ---color-neutral-custom: var(--color-primary); +--color-warn-A200: #ffe1db; +--color-warn-A400: #ffb7a8; +--color-warn-A700: #ffa28f; +--radius-10: 10px; +--radius-2: 2px; +--radius-25: 25px; +--radius-5: 5px; --spacing-0: 0px; --spacing-1: 1px; ---spacing-5: 5px; --spacing-10: 10px; --spacing-20: 20px; --spacing-30: 30px; --spacing-40: 40px; ---radius-2: 2px; ---radius-5: 5px; ---radius-10: 10px; ---radius-25: 25px; +--spacing-5: 5px; /* --- END THEME Auto-generated --- */ } diff --git a/docs/analytics/ANALYTICS.md b/docs/analytics/ANALYTICS.md index d97aed3c37..5dbfc3adcd 100644 --- a/docs/analytics/ANALYTICS.md +++ b/docs/analytics/ANALYTICS.md @@ -1,14 +1,22 @@ # Analytics -Here, you will see how to build step by step your own component UI events to be tracked by the [Analytics Track Event Service](./TRACK_EVENTS.md). + +> [!IMPORTANT] +> This documentation is referring to an outdated way to emit analytics events, please referrer to [Track Events documentation](./TRACK_EVENTS.md) to latest version. +> The mechanism described in this document will be removed in Otter v12. + +Here, you will see how to build step by step your own component UI events to be tracked by the [Analytics Track Event Service *(deprecated)*](./TRACK_EVENTS(deprecated).md). ## Context + The practice of analytics is there for supporting decision-making by providing the relevant facts that will allow you to make better choices. ## How to use + When you generate your component, you can decide to activate the otter analytics structure. ### A new file analytic.ts -The otter component generator will create one file suffixed by `analytics.ts`. + +The otter component generator will create one file suffixed by `analytics.ts`. Inside you will find an interface to define all the events that your component can trigger and a const to inject inside your component. ```typescript @@ -52,6 +60,7 @@ export const analyticsEvents: MyComponentAnalytics = { ``` ### Component file + Your component needs to implement _Trackable_ interface. ```typescript @@ -70,6 +79,8 @@ class MyComponent implements Trackable, ... { } ``` -## TrackEvents +## References -Check [TRACK_EVENTS.md](./TRACK_EVENTS.md) +- Track Analytics Events following [Track Events documentation](./TRACK_EVENTS.md) +- Track Performance Metrics via [Performance measurement documentation](./PERFORMANCE.md) +- *(:warning: deprecated)* Track Analytics Events following [Track Events module documentation](./TRACK_EVENTS(deprecated).md) diff --git a/docs/analytics/PERFORMANCE.md b/docs/analytics/PERFORMANCE.md new file mode 100644 index 0000000000..4a60449dc1 --- /dev/null +++ b/docs/analytics/PERFORMANCE.md @@ -0,0 +1,176 @@ +# Performance metrics + +There are several aspects of a web application that can impact its performance. Network conditions, CPU processing, server-side tasks are a few of them. +Checking how long it took to load the page is not enough to measure the application performances. +Quickly loading something that is not meaningful nor interactive means nothing to the user. That's why one must improve the load +time AND the perceived performance (aka how fast the user perceives the application). +Some of those metrics (load time related and perception metrics) are described below. + +## Metrics + +### First load + +Mark the first load metrics using the [Performance API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming). +This has to be called only once in a single page application, as it is only meaningful for the initial page load. [FirstLoadDataPayload](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/contracts/events-contracts.ts) +interface is the model object for this mark. + +### First paint ([FP](https://developers.google.com/web/tools/lighthouse/audits/first-contentful-paint)) + +This is one of the first metrics for perceived performance. Basically, it measures the time the app takes to answer a +user's first question: Is something happening? Is the navigation successful ? Has the server responded? +The First Paint (FP) measures the time it takes from the start of the navigation to, for example, display the loading indication. + +### First Meaningful Paint ([FMP](https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint)) + +Also for perceived performance, FMP measures the time the app takes to render enough content for users to engage. A simple strategy for this metric is to mark what's called hero elements (most important +elements in the page) and register the time it took to display them + +### Time to Interactive ([TTI](https://developers.google.com/web/tools/lighthouse/audits/time-to-interactive)) + +TTI marks the time when the user can effectively interact with the app. This is closely related to the fact that, in some implementations, the app may have rendered meaningful information +(measured by FMP) but, in the background, it's still doing some kind of computation that blocks any possible interaction with the page. + +The time to interactive is quite tricky as it not only depends on the relevant data readiness, but also on +component internal display mechanics. +If you know exactly where javascript will trigger a layout change (e.g. by passing a boolean variable to true), it's possible to measure the upper bound for the rendering. + +In addition, during a component development, you can't possibly know beforehand if the component will be relevant for a TTI or not, since it depends on the page itself. +For example, the display of a cart component may be relevant for TTI in a given page and not relevant at all in others. +Hence, you cannot really define your TTI logic at component level. + +Given the above facts, we advise to split the TTI metric in two: + +* __dataReady__: This probe marks the time when all the data, needed to the page be interactive, is available +* __TTI per component__: data ready for each component; we advise to implement it later, since it may impact the complexity of the code + +For the time being we will consider only the implementation of __data ready__ + +### Network and server-side metrics + +As the browser can't understand when a route event happens in an SPA, the NavigationTimingAPI can't be directly used apart from the first page load at most. +Subsequent routing changes won't profit of the API connection timings. + +In regard of the __server fetches__ (filter out from the resource timing API), the [PerformanceMetricPlugin](https://github.com/AmadeusITGroup/otter/blob/main/packages/@ama-sdk/core/src/plugins/perf-metric/perf-metric.fetch.ts) +has been put in place to get the metrics associated to server calls. +Check [ServerCallMetric](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/contracts/events-contracts.ts) +model to see which information is saved for each call. + +## How to mark performance metrics? + +The __EventTrackService__ plugs itself to the [NavigationEnd](https://angular.io/api/router/NavigationEnd) router, to handle the performance metrics and exposes the performance object as a stream (observable). +The performance metric object structure is defined by __PerfEventPayload__ interface which can be found [here](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/contracts/events-contracts.ts). +The service provides a way to activate/deactivate performance measurements. By default, it's __activated__ and we expose a public method called __togglePerfTracking__ to activate/deactivate it. +For instance if you want to deactivate it, call this in your app: + +```typescript +import {EventTrackService} from '@o3r/analytics'; +... +constructor(trackService: EventTrackService) { + trackService.togglePerfTracking(false); +} +``` + +### Tracking configuration + +You can override the default configuration via a configuration token ([EVENT_TRACK_SERVICE_CONFIGURATION](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/services/event-track/event-track.configuration.ts)). +Example of configuration override: + +```typescript +// in app module + ... + providers: [ + ... + {provide: EVENT_TRACK_SERVICE_CONFIGURATION, useValue: {useBrowserApiForFirstFP: true}} + ] +``` + +More details about the configuration object and [defaultEventTrackConfiguration](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/services/event-track/event-track.configuration.ts) can be found [here](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/services/event-track/event-track.configuration.ts) + +#### First load measurement + +This mark is populated by default by the __EventTrackService__ when the [NavigationEnd](https://angular.io/api/router/NavigationEnd) event of the router emits for the first time. + +#### First paint (FP) + +You can mark the time the loading is rendered. + +* If the app has a loading indicator at [NavigationStart](https://angular.io/api/router/NavigationStart), this is when we want to mark the first paint. + +```typescript +// app component +... + constructor(private router: Router, public trackEventsService: EventTrackService) {} + ngOnInit() { + this.subscriptions.push(this.router.events.subscribe((event) => this.setLoadingIndicator(event))); + ... + } + setLoadingIndicator(event: Event) { + if (event instanceof NavigationStart) { + this.loading = true; + this.trackEventsService.markFP(); // ----> mark the first paint here + } + } +``` + +* If __index.html__ contains a loading indicator, it will be rendered even before loading angular; +In this case FP will be marked by the browser api. You can activate this behaviour in the tracking service and override the '_useBrowserApiForFirstFP_' config property to _true_; +If the browser does not have [performance entry 'paint' api](https://developer.mozilla.org/en-US/docs/Web/API/Performance/getEntriesByType), nothing will be marked. + +```typescript +// in app module + ... + providers: [ + ... + {provide: EVENT_TRACK_SERVICE_CONFIGURATION, useValue: {useBrowserApiForFirstFP: true}} + ] +``` + +* __markFP__ method from tracking service should be called when the loading indicator is triggered + +#### First Meaningful Paint (FMP) + +You can mark FMP is in the _ngAfterViewInit_ of each page + +```typescript +// Search component + +constructor(... , private trackEventsService: EventTrackService) {...} + +ngAfterViewInit() { + this.trackEventsService.markFMP(); +} +``` + +#### Data Ready + +This will depend on your application. +For example, on the availability page, mark _data ready_ when the calendar and offers data are available; + +```typescript +// upsell page component +... +export class UpsellComponent implements OnInit, OnDestroy, Configurable { + ... + constructor(public trackEventsService: EventTrackService, private store: Store) { + ... + } + + ngOnInit() { + const airCalendarReady$ = this.store.pipe( + select(selectAirCalendarState), + filter((state) => state.isPending === false && state.isFailure === false) + ); + const airOffersReady$ = this.store.pipe( + select(selectAirOffersIds), + filter((ids) => !!ids.length) + ); + this.subscriptions.push( + combineLatest(airCalendarReady$, airOffersReady$) + .pipe(take(1)) + .subscribe(([_airCalendar, _airOffersIds]) => { + this.trackEventsService.markDataReady(); /// ----> mark data ready when both calendar and offres data are in the store + })); + } + ... +} +``` diff --git a/docs/analytics/TRACK_EVENTS(deprecated).md b/docs/analytics/TRACK_EVENTS(deprecated).md new file mode 100644 index 0000000000..2446cece0b --- /dev/null +++ b/docs/analytics/TRACK_EVENTS(deprecated).md @@ -0,0 +1,118 @@ +# Track UI Events + +The main purpose of this mechanism is to ease event tracking at component level. +You can capture your events via the tracking event directives (exposed in the [TrackEventsModule](#trackeventsmodule)) +and the [EventTrackService](#eventtrackservice). + +You can access all these events via the [EventTrackService](#eventtrackservice). + +## EventTrackService + +This service is used to store the event objects and to expose them as a stream (observable) to your application. +It controls the analytics activation and deactivation as a whole or per feature (ui, performance etc.). + +You can directly access the service `EventTrackService` inside your component to capture new events. + +```typescript +import {EventTrackService} from '@o3r/analytics'; +import {analyticsEvents, MyComponentAnalytics} from './my-component.analytics'; + +class MyComponent extends Trackable, ... { + ... + + /** + * @inheritDoc + */ + public readonly analyticsEvents: MyComponentAnalytics = analyticsEvents; + + constructor(..., private eventTrackService: EventTrackService) { + ... + } + + ... + + somethingHappened() { + this.eventTrackService.addUiEvent(new analyticsEvents.dummyEvent()) + } +} +``` + +## TrackEventsModule + +The `TrackEventsModule` contains directives to help you track standard event such as the `TrackClickDirective` or +`TrackFocusDirective`. +You can track more standard ui event with the `TrackEventsDirective` and even create your own component events +(see [Analytics Events](./ANALYTICS.md)). +Note that all these events will be stored as UI Events in the [EventTrackService](#eventtrackservice). + +```html + + +``` + +### TrackEvents directive + +The directive will listen to the events on the element on which was applied and will expose the event captured using the track service. + +| Input Name | Description | Possible Values | +| ----------------- | ------------------------------------------------------ | ------------------------------- | +| trackEvents | List of events which have to be tracked | ['mouseover', 'mouseenter'] | +| trackEventContext | Custom object to be exposed when the event is captured | {context: 'continueBtnClicked'} | + +A specific directive for the click event was created, as it is the most used tracked event. + +### Directive usage + +```html + + ... + +``` + +in component.ts file + +```typescript +eventModel = {name: 'searchBtnMouseEvent'}; +``` + +in eventContext pipe.ts file + +```typescript +transform(value: any, itinerary: any): any { + return {...value, itinerary}; +} +``` + +### Application level + +At application level a subscription can be done to the observable emitted by the track events service. +You can enhance your analytics data and merge/concatenate/modify the event from the `TrackEventsService` with your own +application store. + diff --git a/docs/analytics/TRACK_EVENTS.md b/docs/analytics/TRACK_EVENTS.md index e4c63b64c9..720dbb9f20 100644 --- a/docs/analytics/TRACK_EVENTS.md +++ b/docs/analytics/TRACK_EVENTS.md @@ -1,268 +1,322 @@ -# Track UI Events +# Track Analytics Events The main purpose of this mechanism is to ease event tracking at component level. -You can capture your events via the tracking event directives (exposed in the [TrackEventsModule](#TrackEventsModule)) -and the [EventTrackService](#EventTrackService). +You can capture your events via the tracking [event directives](#directives). -You can access all these events via the [EventTrackService](#EventTrackService). +You can emit [Analytics Events](#event) directly via the [AnalyticsEventReporter](#analytics-event-reporter). +You can access all these events via the __events$__ stream exposed by [AnalyticsEventReporter](#analytics-event-reporter). -## EventTrackService -This service is used to store the event objects and to expose them as a stream (observable) to your application. -It controls the analytics activation and deactivation as a whole or per feature (ui, performance etc.). +## Setup Analytics reporter -You can directly access the service `EventTrackService` inside your component to capture new events. +The `AnalyticsEventReporter` is the central service gathering and reporting the data to the different third party Analytics solution [registered](#register-analytics-services). + +Per default, the Analytics service collects analytics data as soon as the `AnalyticsEventReporter` service is imported by the application (the different [event directives](#directives) are automatically importing the service). +If no third party Analytics services are registered, the `AnalyticsEventReporter` service keep a buffer of the emitted Analytics event until at least one Analytics service is registered. The size of this buffer is configurable via the [AnalyticsEventReporter's configuration](#analytics-event-reporter). + +There are 2 ways to start manually the collect of the Analytics events: + +__1. Via module load__ ```typescript -import {EventTrackService} from '@o3r/analytics'; -import {analyticsEvents, MyComponentAnalytics} from './my-component.analytics'; +// in main.ts file -class MyComponent extends Trackable, ... { - ... +import { AnalyticsTrackerModule } from '@o3r/analytics'; - /** - * @inheritDoc - */ - public readonly analyticsEvents: MyComponentAnalytics = analyticsEvents; +bootstrapApplication(AppComponent, { + providers: [ + importProvidersFrom( + AnalyticsTrackerModule.forRoot({ + //... Configuration of the Analytics services + trackerConfig: { activatedOnBootstrap: true } + }), + ) + ] +}); - constructor(..., private eventTrackService: EventTrackService) { - ... - } +``` - ... +> [!NOTE] +> The option `activatedOnBootstrap` is at `true` per default, the explicit set of this property is not required. + +__2. Via Token Injection__ + +```typescript +// in a component (or specially ion the AppComponent) + +import { ANALYTICS_REPORTER_CONFIGURATION, AnalyticsEventReporter, defaultAnalyticsReporterConfiguration } from '@o3r/analytics'; + +@NgComponent({ + // ... + providers: [ + { provide: ANALYTICS_REPORTER_CONFIGURATION, useFactory: () => { + return { + ...defaultAnalyticsReporterConfiguration, + activatedOnBootstrap: true + }; + } }, + AnalyticsEventReporter + ] +}) +class AppComponent { - somethingHappened() { - this.eventTrackService.addUiEvent(new analyticsEvents.dummyEvent()) - } } ``` -## TrackEventsModule -The `TrackEventsModule` contains directives to help you track standard event such as the `TrackClickDirective` or -`TrackFocusDirective`. -You can track more standard ui event with the `TrackEventsDirective` and even create your own component events -(see [Analytics Events](./ANALYTICS.md)). -Note that all these events will be stored as UI Events in the [EventTrackService](#EventTrackService). -```html - - +__3. Via direct service import__ + +```typescript +// in a component + +import { AnalyticsEventReporter } from '@o3r/analytics'; + +@NgComponent(/* ... */) +class MyComponent { + private analytics = inject(AnalyticsEventReporter); + + public toggleAnalytics() { + this.analytics.isTrackingActive.set(!this.analytics.isTrackingActive()); + } +} ``` +### Register Analytics Services -### TrackEvents directive +To report the captured analytics events, the `AnalyticsEventReporter` should know the different Analytics service to report the events too. -The directive will listen to the events on the element on which was applied and will expose the event captured using the track service. +The Otter framework comes with implementation of interfaces to third party Analytics services, available via the [Analytics services list](#available-analytics-services), and also give the possibility to implement its [own Analytics service interface](#third-party-analytics-service). -| Input Name | Description | Possible Values | -| ----------------- | ------------------------------------------------------ | ------------------------------- | -| trackEvents | List of events which have to be tracked | ['mouseover', 'mouseenter'] | -| trackEventContext | Custom object to be exposed when the event is captured | {context: 'continueBtnClicked'} | +The Analytics services can be registered in 2 different ways: -A specific directive for the click event was created, as it is the most used tracked event. +__1. At bootstrap time__ via the `AnalyticsTrackerModule` options (or the `ANALYTICS_REPORTER_CONFIGURATION` token injection) -### Directive usage +```typescript +// in main.ts file -```html - - ... - +__2. At run time__ via the usage of the `AnalyticsEventReporter` service + +```typescript +// in a component + +import { AnalyticsEventReporter } from '@o3r/analytics'; +import { createGoogleAnalyticsService } from '@o3r/analytics'; + +@NgComponent(/* ... */) +class MyComponent { + private analytics = inject(AnalyticsEventReporter); + + public addGoogleAnalytics() { + const gaInstance = createGoogleAnalyticsService({uuidL 'my-uuid'}); + return this.registerAnalyticsServices(gaInstance); + } +} ``` -in component.ts file +### Listen page change events + +The `@o3r/analytics` package exposes a service dedicated to listen and report the Angular Router events called [AnalyticsRouterTracker](#analytics-router-service). + +The service just need to be injected to run: + ```typescript -eventModel = {name: 'searchBtnMouseEvent'}; +// in main.ts file + +import { AnalyticsRouterTracker } from '@o3r/analytics'; + +bootstrapApplication(AppComponent, { + providers: [ + AnalyticsRouterTracker + ] +}); ``` -in eventContext pipe.ts file +or can be activated via the `AnalyticsTrackerModule` module ```typescript -transform(value: any, itinerary: any): any { - return {...value, itinerary}; -} +// in main.ts file + +import { AnalyticsTrackerModule } from '@o3r/analytics'; + +bootstrapApplication(AppComponent, { + providers: [ + importProvidersFrom( + AnalyticsTrackerModule.forRoot({ + enableRouterTracker: true + }), + ) + ] +}); + ``` -### Application level +> [!NOTE] +> the `AnalyticsRouterTracker` is active only when the Analytics report is activated. It loads the `AnalyticsRouterTracker` service if it is not already loaded by the application. -At application level a subscription can be done to the observable emitted by the track events service. -You can enhance your analytics data and merge/concatenate/modify the event from the `TrackEventsService` with your own -application store. +## Event -# Performance metrics +To support multiple [Third party Analytics service](#third-party-analytics-service), the Otter framework defined a custom layer of events. +To simplify the implementation of event emitter, and the handle of these events by the Third party Analytics services, a set of interfaces are exposed for *wellknown* events: -There are several aspects of a web application that can impact its performance. Network conditions, CPU processing, server-side tasks are a few of them. -Checking how long it took to load the page is not enough to measure the application performances. -Quickly loading something that is not meaningful nor interactive means nothing to the user. That's why one must improve the load -time AND the perceived performance (aka how fast the user perceives the application). -Some of those metrics (load time related and perception metrics) are described below. +- __AnalyticsClickEvent__: Click events +- __AnalyticsFocusEvent__: Focus events +- __AnalyticsPageViewEvent__: Route change events +- __AnalyticsExceptionEvent__: Exception (warning and error) events -### First load -Mark the first load metrics using the [Performance API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming). -This has to be called only once in a single page application, as it is only meaningful for the initial page load. [FirstLoadDataPayload](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/contracts/events-contracts.ts) -interface is the model object for this mark. +To define a __Custom Event__ (not part of the previous list), the action should implement the `AnalyticsGenericEvent` which will enforce the __action name__ to be prefixed with `_`. -### First paint ([FP](https://developers.google.com/web/tools/lighthouse/audits/first-contentful-paint)) -This is one of the first metrics for perceived performance. Basically, it measures the time the app takes to answer a -user's first question: Is something happening? Is the navigation successful ? Has the server responded? -The First Paint (FP) measures the time it takes from the start of the navigation to, for example, display the loading indication. +> [!WARNING] +> Per definition, the Custom Event does not have proper conversion to the [provided Third party adapters](#third-party-analytics-service). It may not be reported to certain services or to produce warnings. -### First Meaningful Paint ([FMP](https://developers.google.com/web/tools/lighthouse/audits/first-meaningful-paint)) -Also for perceived performance, FMP measures the time the app takes to render enough content for users to engage. A simple strategy for this metric is to mark what's called hero elements (most important -elements in the page) and register the time it took to display them +## Tools -### Time to Interactive ([TTI](https://developers.google.com/web/tools/lighthouse/audits/time-to-interactive)) -TTI marks the time when the user can effectively interact with the app. This is closely related to the fact that, in some implementations, the app may have rendered meaningful information -(measured by FMP) but, in the background, it's still doing some kind of computation that blocks any possible interaction with the page. +The `@o3r/analytics` comes with several tools to help to report analytics data based on the application final users actions on the UI. -The time to interactive is quite tricky as it not only depends on the relevant data readiness, but also on -component internal display mechanics. -If you know exactly where javascript will trigger a layout change (e.g. by passing a boolean variable to true), it's possible to measure the upper bound for the rendering. +### Directives -In addition, during a component development, you can't possibly know beforehand if the component will be relevant for a TTI or not, since it depends on the page itself. -For example, the display of a cart component may be relevant for TTI in a given page and not relevant at all in others. -Hence, you cannot really define your TTI logic at component level. +Today 3 different directives are exposed by `@o3r/analytics` to react on specific HTML events. +This directives come with dedicated attributes to specified the analytics event to emit. -Given the above facts, we advise to split the TTI metric in two: - * __dataReady__: This probe marks the time when all the data, needed to the page be interactive, is available - * __TTI per component__: data ready for each component; we advise to implement it later, since it may impact the complexity of the code +#### Analytics Click directive -For the time being we will consider only the implementation of __data ready__ +The directive is exposed as __standalone module__ under the name `AnalyticTrackClick` and should be imported by the component using it. -### Network and server-side metrics -As the browser can't understand when a route event happens in an SPA, the NavigationTimingAPI can't be directly used apart from the first page load at most. -Subsequent routing changes won't profit of the API connection timings. +To be applied to an Element, the following attribute need to be provided: -In regard of the __server fetches__ (filter out from the resource timing API), the [PerformanceMetricPlugin](https://github.com/AmadeusITGroup/otter/blob/main/packages/@ama-sdk/core/src/plugins/perf-metric/perf-metric.fetch.ts) -has been put in place to get the metrics associated to server calls. -Check [ServerCallMetric](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/contracts/events-contracts.ts) -model to see which information is saved for each call. +- __trackClick__: Indicate that the click event should be listen. If a value is specified, it will be used as default event value in case `trackClickValue` is not specified. -## How to mark performance metrics? +The following attributes can be specified (not mandatory) to indicate additional information to the event: -The __EventTrackService__ plugs itself to the [NavigationEnd](https://angular.io/api/router/NavigationEnd) router, to handle the performance metrics and exposes the performance object as a stream (observable). -The performance metric object structure is defined by __PerfEventPayload__ interface which can be found [here](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/contracts/events-contracts.ts). -The service provides a way to activate/deactivate performance measurements. By default, it's __activated__ and we expose a public method called __togglePerfTracking__ to activate/deactivate it. -For instance if you want to deactivate it, call this in your app: -```typescript -import {EventTrackService} from '@o3r/analytics'; -... -constructor(trackService: EventTrackService) { - trackService.togglePerfTracking(false); -} -``` -### Tracking configuration -You can override the default configuration via a configuration token ([EVENT_TRACK_SERVICE_CONFIGURATION](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/services/event-track/event-track.configuration.ts)). -Example of configuration override: -```typescript -// in app module - ... - providers: [ - ... - {provide: EVENT_TRACK_SERVICE_CONFIGURATION, useValue: {useBrowserApiForFirstFP: true}} - ] +- __trackClickCategory__: Indicate the __category__ of the reported event. +- __trackClickAction__: Indicate the __action name__ of the reported event. If not specified, the name *click* will be used. +- __trackClickLabel__: Indicate a __label__ to apply to the reported event. +- __trackClickValue__: Indicate a __value__ to the reported event. If not specified, the value indicated in the `trackClick` attribute will be used. + +Usage example: + +```html + ``` -More details about the configuration object and [defaultEventTrackConfiguration](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/services/event-track/event-track.configuration.ts) can be found [here](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/analytics/src/services/event-track/event-track.configuration.ts) -#### First load +#### Analytics Focus directive -This mark is populated by default by the __EventTrackService__ when the [NavigationEnd](https://angular.io/api/router/NavigationEnd) event of the router emits for the first time. +The directive is exposed as __standalone module__ under the name `AnalyticTrackFocus` and should be imported by the component using it. -#### First paint (FP) +To be applied to an Element, the following attribute need to be provided: -You can mark the time the loading is rendered. -* If the app has a loading indicator at [NavigationStart](https://angular.io/api/router/NavigationStart), this is when we want to mark the first paint. -```typescript -// app component -... - constructor(private router: Router, public trackEventsService: EventTrackService) {} - ngOnInit() { - this.subscriptions.push(this.router.events.subscribe((event) => this.setLoadingIndicator(event))); - ... - } - setLoadingIndicator(event: Event) { - if (event instanceof NavigationStart) { - this.loading = true; - this.trackEventsService.markFP(); // ----> mark the first paint here - } - } +- __trackFocus__: Indicate that the focus event should be listen. If a value is specified, it will be used as default event value in case `trackFocusValue` is not specified. + +The following attributes can be specified (not mandatory) to indicate additional information to the event: + +- __trackFocusCategory__: Indicate the __category__ of the reported event. +- __trackFocusAction__: Indicate the __action name__ of the reported event. If not specified, the name *focus* will be used. +- __trackFocusLabel__: Indicate a __label__ to apply to the reported event. +- __trackFocusValue__: Indicate a __value__ to the reported event. If not specified, the value indicated in the `trackFocus` attribute will be used. + +Usage example: + +```html + ``` -* If __index.html__ contains a loading indicator, it will be rendered even before loading angular; -In this case FP will be marked by the browser api. You can activate this behaviour in the tracking service and override the '_useBrowserApiForFirstFP_' config property to _true_; -If the browser does not have [performance entry 'paint' api](https://developer.mozilla.org/en-US/docs/Web/API/Performance/getEntriesByType), nothing will be marked. -```typescript -// in app module - ... - providers: [ - ... - {provide: EVENT_TRACK_SERVICE_CONFIGURATION, useValue: {useBrowserApiForFirstFP: true}} - ] + +#### Analytics events directive + +The directive is exposed as __standalone module__ under the name `AnalyticTrackEvent` and should be imported by the component using it. + +To be applied to an Element, the following attribute need to be provided: + +- __trackEvents__: Indicate that the Dom event(s) that should be listen. + +The following attributes can be specified (not mandatory) to indicate additional information to the event: + +- __trackCategory__: Indicate the __category__ of the reported event(s). +- __trackAction__: Indicate the __action name__ of the reported event. If not specified, the name of the event will be used. +- __trackLabel__: Indicate a __label__ to apply to the reported event(s). +- __trackValue__: Indicate a __value__ to the reported event(s). If not specified, the value of the element will be used. + +Usage example (multi events): + +```html + ``` -* __markFP__ method from tracking service should be called when the loading indicator is triggered - -#### First Meaningful Paint (FMP) -You can mark FMP is in the _ngAfterViewInit_ of each page -```typescript -// Search component -constructor(... , private trackEventsService: EventTrackService) {...} +Usage example (single event): -ngAfterViewInit() { - this.trackEventsService.markFMP(); -} +```html + ``` -#### Data Ready -This will depend on your application. -For example, on the availability page, mark _data ready_ when the calendar and offers data are available; -```typescript -// upsell page component -... -export class UpsellComponent implements OnInit, OnDestroy, Configurable { - ... - constructor(public trackEventsService: EventTrackService, private store: Store) { - ... - } - ngOnInit() { - const airCalendarReady$ = this.store.pipe( - select(selectAirCalendarState), - filter((state) => state.isPending === false && state.isFailure === false) - ); - const airOffersReady$ = this.store.pipe( - select(selectAirOffersIds), - filter((ids) => !!ids.length) - ); - this.subscriptions.push( - combineLatest(airCalendarReady$, airOffersReady$) - .pipe(take(1)) - .subscribe(([_airCalendar, _airOffersIds]) => { - this.trackEventsService.markDataReady(); /// ----> mark data ready when both calendar and offres data are in the store - })); - } - ... -} +> [!NOTE] +> To apply different *category*, *value* or *action* per events, the dedicated directives should be applied together. + +Multi directive example: + +```html + ``` +### Analytics event reporter + +The service `AnalyticsEventReporter` is loaded in root (as singleton) to report the analytics events to the registered Analytics services. +It exposes only 2 functions: + +- __registerAnalyticsServices__: to register third party Analytics service(s) during the application life. +- __reportEvent__: report manually an [analytics event](#event) to be dispatched to the registered third party Analytics service(s). + +The `AnalyticsEventReporter` service can be configured via the `ANALYTICS_REPORTER_CONFIGURATION` (see [Track Analytics Events](#track-analytics-events)) with the following options: + +- __eventStackSize__: size of the event stack to keep when no Analytics service registered. +- __activatedOnBootstrap__: determine if the service is collecting analytics on the load of the service. +- __registeredAnalyticsServicesOnBootstrap__: list of Analytics services to register on bootstrap of the service. + +### Analytics Logger + +The package `@o3r/analytics` exposes the [logger](../logger/LOGS.md) __AnalyticsExceptionLogger__ (to [register to the `LoggerService`](../logger/LOGS.md#setup)) to report the __warnings__ and __errors__ via the `AnalyticsEventReporter`. + +### Analytics Router Service + +To automatically report the routing changes to the Analytics services, the `@o3r/analytics` exposes the __AnalyticsRouterTracker__ service, provided in root, that can manually provided or loaded thanks to the [AnalyticsTrackerModule](#setup-analytics-reporter). + +## Available Analytics Services + +To be able to be registered as to the [Reporter Analytics service](#analytics-event-reporter), the Third party Analytics services need to implement the [Analytics interface](#third-party-analytics-service). +The `@o3r/analytics` already expose a set of factory function returning the adapters to Third party services: + +| Analytics Service | Factory function | Parameters | +| ----------------- | ------------------------------ | ---------------------------------------------- | +| Google Analytics | `createGoogleAnalyticsService` | - __uuid__: ID of the Google Analytics account | + +## Third Party Analytics service + +A Third Party service adapter should implement the `AnalyticsThirdPartyService` which enforce the exposition of the __emit__ and allow the following hooks: +- __onRegistration__: Hook called when the services has been registered to the `AnalyticsEventReporter`. +- __onActivation__: Hook called when the the `AnalyticsEventReporter` ios deactivated. +- __onDeactivation__: Hook called by the `AnalyticsEventReporter` to emit an Analytics Event to the service implemented this interface. diff --git a/packages/@o3r/analytics/README.md b/packages/@o3r/analytics/README.md index dd235258c7..e351a48a67 100644 --- a/packages/@o3r/analytics/README.md +++ b/packages/@o3r/analytics/README.md @@ -35,4 +35,4 @@ Otter framework provides a set of code generators based on [angular schematics]( ## More information -Find more information in the [documentation](https://github.com/AmadeusITGroup/otter/tree/main/docs/analytics/ANALYTICS.md). +Find more information in the [documentation](https://github.com/AmadeusITGroup/otter/tree/main/docs/analytics/TRACK_EVENTS.md). diff --git a/packages/@o3r/analytics/package.json b/packages/@o3r/analytics/package.json index 3d8eb038dc..a815aa9867 100644 --- a/packages/@o3r/analytics/package.json +++ b/packages/@o3r/analytics/package.json @@ -32,6 +32,7 @@ "@angular/router": "~18.1.0", "@ngrx/store": "~18.0.0", "@o3r/core": "workspace:^", + "@o3r/logger": "workspace:^", "@o3r/schematics": "workspace:^", "@schematics/angular": "~18.1.0", "jasmine": "^5.0.0", @@ -39,6 +40,9 @@ "webpack": "~5.93.0" }, "peerDependenciesMeta": { + "@o3r/logger": { + "optional": true + }, "@o3r/schematics": { "optional": true }, @@ -80,6 +84,7 @@ "@o3r/build-helpers": "workspace:^", "@o3r/core": "workspace:^", "@o3r/eslint-plugin": "workspace:^", + "@o3r/logger": "workspace:^", "@o3r/test-helpers": "workspace:^", "@schematics/angular": "~18.1.0", "@stylistic/eslint-plugin-ts": "~2.4.0", diff --git a/packages/@o3r/analytics/schematics/analytics-to-component/templates/__name__.analytics.ts.template b/packages/@o3r/analytics/schematics/analytics-to-component/templates/__name__.analytics.ts.template index 7d72a7597a..a790985058 100644 --- a/packages/@o3r/analytics/schematics/analytics-to-component/templates/__name__.analytics.ts.template +++ b/packages/@o3r/analytics/schematics/analytics-to-component/templates/__name__.analytics.ts.template @@ -1,4 +1,4 @@ -import {<% if (activateDummy) { %>EventInfo, AnalyticsEvent, Attribute, ConstructorAnalyticsEvent, ConstructorAnalyticsEventParameters,<% } %> AnalyticsEvents} from '@o3r/analytics'; +import type {<% if (activateDummy) { %>EventInfo, AnalyticsEvent, Attribute, ConstructorAnalyticsEvent, ConstructorAnalyticsEventParameters,<% } %> AnalyticsEvents} from '@o3r/analytics'; <% if (activateDummy) { %>/** * Dummy event to show how we can use analytics event diff --git a/packages/@o3r/analytics/src/fixtures/jasmine/event-track.service.fixture.jasmine.ts b/packages/@o3r/analytics/src/fixtures/jasmine/event-track.service.fixture.jasmine.ts index 4ee9b4576c..70c46de2f2 100644 --- a/packages/@o3r/analytics/src/fixtures/jasmine/event-track.service.fixture.jasmine.ts +++ b/packages/@o3r/analytics/src/fixtures/jasmine/event-track.service.fixture.jasmine.ts @@ -38,19 +38,28 @@ export class EventTrackServiceFixture implements Readonly; - /** UI captured events as stream */ + /** + * UI captured events as stream + * @deprecated use {@link AnalyticsEventReporter} instead, will be removed in v12 + */ public uiEventTrack$: Observable; - /** Custom captured events as stream */ + /** + * Custom captured events as stream + * @deprecated use {@link AnalyticsEventReporter} instead, will be removed in v12 + */ public customEventTrack$: Observable; /** Performance captured events as stream */ public perfEventTrack$: Observable; - /** Stream of booleans for the ui tracking mode active/inactive */ + /** + * Stream of booleans for the ui tracking mode active/inactive + * @deprecated use {@link AnalyticsEventReporter} instead, will be removed in v12 + */ public uiTrackingActive$: Observable; /** Stream of booleans for the performance tracking mode active/inactive */ @@ -329,6 +338,7 @@ export class EventTrackService { /** * Add an event to the stream of captured UI events * @param uiEvent emitted event object + * @deprecated use {@link AnalyticsEventReporter.reportEvent} instead, will be removed in v12 */ public addUiEvent(uiEvent: UiEventPayload) { this.uiEventTrack.next(uiEvent); @@ -345,6 +355,7 @@ export class EventTrackService { /** * Activate/deactivate the tracking mode for UI events * @param activate activation/deactivation boolean + * @deprecated use {@link AnalyticsEventReporter.isTrackingActive} instead, will be removed in v12 */ public toggleUiTracking(activate: boolean) { this.uiTrackingActivated.next(activate); diff --git a/packages/@o3r/analytics/src/services/event-track/index.ts b/packages/@o3r/analytics/src/performance/services/event-track/index.ts similarity index 100% rename from packages/@o3r/analytics/src/services/event-track/index.ts rename to packages/@o3r/analytics/src/performance/services/event-track/index.ts diff --git a/packages/@o3r/analytics/src/services/index.ts b/packages/@o3r/analytics/src/performance/services/index.ts similarity index 100% rename from packages/@o3r/analytics/src/services/index.ts rename to packages/@o3r/analytics/src/performance/services/index.ts diff --git a/packages/@o3r/analytics/src/stores/event-track/event-track.actions.ts b/packages/@o3r/analytics/src/performance/stores/event-track/event-track.actions.ts similarity index 100% rename from packages/@o3r/analytics/src/stores/event-track/event-track.actions.ts rename to packages/@o3r/analytics/src/performance/stores/event-track/event-track.actions.ts diff --git a/packages/@o3r/analytics/src/stores/event-track/event-track.module.ts b/packages/@o3r/analytics/src/performance/stores/event-track/event-track.module.ts similarity index 100% rename from packages/@o3r/analytics/src/stores/event-track/event-track.module.ts rename to packages/@o3r/analytics/src/performance/stores/event-track/event-track.module.ts diff --git a/packages/@o3r/analytics/src/stores/event-track/event-track.reducer.spec.ts b/packages/@o3r/analytics/src/performance/stores/event-track/event-track.reducer.spec.ts similarity index 100% rename from packages/@o3r/analytics/src/stores/event-track/event-track.reducer.spec.ts rename to packages/@o3r/analytics/src/performance/stores/event-track/event-track.reducer.spec.ts diff --git a/packages/@o3r/analytics/src/stores/event-track/event-track.reducer.ts b/packages/@o3r/analytics/src/performance/stores/event-track/event-track.reducer.ts similarity index 100% rename from packages/@o3r/analytics/src/stores/event-track/event-track.reducer.ts rename to packages/@o3r/analytics/src/performance/stores/event-track/event-track.reducer.ts diff --git a/packages/@o3r/analytics/src/stores/event-track/event-track.selectors.spec.ts b/packages/@o3r/analytics/src/performance/stores/event-track/event-track.selectors.spec.ts similarity index 100% rename from packages/@o3r/analytics/src/stores/event-track/event-track.selectors.spec.ts rename to packages/@o3r/analytics/src/performance/stores/event-track/event-track.selectors.spec.ts diff --git a/packages/@o3r/analytics/src/stores/event-track/event-track.selectors.ts b/packages/@o3r/analytics/src/performance/stores/event-track/event-track.selectors.ts similarity index 100% rename from packages/@o3r/analytics/src/stores/event-track/event-track.selectors.ts rename to packages/@o3r/analytics/src/performance/stores/event-track/event-track.selectors.ts diff --git a/packages/@o3r/analytics/src/stores/event-track/event-track.state.ts b/packages/@o3r/analytics/src/performance/stores/event-track/event-track.state.ts similarity index 100% rename from packages/@o3r/analytics/src/stores/event-track/event-track.state.ts rename to packages/@o3r/analytics/src/performance/stores/event-track/event-track.state.ts diff --git a/packages/@o3r/analytics/src/stores/event-track/event-track.sync.ts b/packages/@o3r/analytics/src/performance/stores/event-track/event-track.sync.ts similarity index 100% rename from packages/@o3r/analytics/src/stores/event-track/event-track.sync.ts rename to packages/@o3r/analytics/src/performance/stores/event-track/event-track.sync.ts diff --git a/packages/@o3r/analytics/src/stores/event-track/index.ts b/packages/@o3r/analytics/src/performance/stores/event-track/index.ts similarity index 100% rename from packages/@o3r/analytics/src/stores/event-track/index.ts rename to packages/@o3r/analytics/src/performance/stores/event-track/index.ts diff --git a/packages/@o3r/analytics/src/stores/index.ts b/packages/@o3r/analytics/src/performance/stores/index.ts similarity index 100% rename from packages/@o3r/analytics/src/stores/index.ts rename to packages/@o3r/analytics/src/performance/stores/index.ts diff --git a/packages/@o3r/analytics/src/public_api.ts b/packages/@o3r/analytics/src/public_api.ts index 9e48d54631..6092f09f3f 100644 --- a/packages/@o3r/analytics/src/public_api.ts +++ b/packages/@o3r/analytics/src/public_api.ts @@ -1,5 +1,2 @@ -export * from './contracts/index'; -export * from './directives/index'; -export * from './services/index'; -export * from './stores/index'; - +export * from './performance/index'; +export * from './tracker/index'; diff --git a/packages/@o3r/analytics/src/tracker/directives/click-event.directive.ts b/packages/@o3r/analytics/src/tracker/directives/click-event.directive.ts new file mode 100644 index 0000000000..1f104f668d --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/directives/click-event.directive.ts @@ -0,0 +1,30 @@ +import { Directive, input } from '@angular/core'; +import { AnalyticTrackGeneric } from './generic-event.directive'; + +/** + * Directive to listen and emit analytics in case of Dom Click event + * @example Button click event + * ```html + * + * ``` + */ +@Directive({ + selector: '[trackClick]', + standalone: true +}) +export class AnalyticTrackClick extends AnalyticTrackGeneric { + /** @inheritdoc */ + public trackEvent = input(undefined, { alias: 'trackClick' }); + /** @inheritdoc */ + public trackCategory = input('', { alias: 'trackClickCategory' }); + /** @inheritdoc */ + public trackAction = input(undefined, { alias: 'trackClickAction' }); + /** @inheritdoc */ + public trackLabel = input(undefined, { alias: 'trackClickLabel' }); + /** @inheritdoc */ + public trackValue = input(undefined, { alias: 'trackClickValue' }); + /** @inheritdoc */ + public readonly eventName = 'click'; +} diff --git a/packages/@o3r/analytics/src/tracker/directives/events.directive.ts b/packages/@o3r/analytics/src/tracker/directives/events.directive.ts new file mode 100644 index 0000000000..b66b140b29 --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/directives/events.directive.ts @@ -0,0 +1,97 @@ +import { Directive, effect, ElementRef, inject, input,type OnInit, Renderer2 } from '@angular/core'; +import { AnalyticsEventReporter } from '../services/tracker/analytics-reporter.service'; +import type { AnalyticsWellKnownDomActionType } from '../events'; + +type TrackEventName = keyof GlobalEventHandlersEventMap; + +/** + * Directive to track one or several Dom Events + * @example Multi events + * ```html + * + * ``` + * @example Single event + * ```html + * + * ``` + */ +@Directive({ + selector: '[trackEvents]', + standalone: true +}) +export class AnalyticTrackEvent implements OnInit { + /** List the Dom Element events to listen and for which emitting analytics event. */ + public trackEvents = input.required(); + /** Category of the events */ + public trackCategory = input(''); + /** + * Name of the action as defined in analytics service. + * The name of the Dom Event will be used if not specified. + */ + public trackAction = input(); + /** Label of the events */ + public trackLabel = input(); + /** Value of the events */ + public trackValue = input(); + + protected readonly el = inject(ElementRef); + protected readonly trackEventsService = inject(AnalyticsEventReporter); + protected readonly renderer = inject(Renderer2); + protected readonly isTrackingActive; + protected listeningEvents: {[x in TrackEventName]?: () => void} = {}; + + constructor() { + this.isTrackingActive = this.trackEventsService.isTrackingActive; + } + + /** + * Create the listener for the given event + * @param eventName name of the event to listen + */ + protected nativeListen(eventName: TrackEventName) { + // Renderer is used because it is manipulating the DOM and when the element is destroyed the event listener is destroyed too. + // Usage of an observable from event was not possible because the ngOnDestroy with the unsubscribe was called before the ui event was handled + return this.renderer.listen(this.el.nativeElement, eventName, (event) => { + const action = this.trackAction() || eventName as AnalyticsWellKnownDomActionType; + this.trackEventsService.reportEvent({ + type: 'event', + action, + category: this.trackCategory(), + label: this.trackLabel(), + event, + attributes: this.trackValue() || this.el.nativeElement?.value + }); + }); + } + + /** Remove the created events listeners */ + protected unlisten() { + Object.values(this.listeningEvents).forEach((fn) => fn); + this.listeningEvents = {}; + } + + public ngOnInit(): void { + effect(() => { + const analyticEvent = this.trackEvents(); + if (this.isTrackingActive()) { + this.listeningEvents = { + ...this.listeningEvents, + ...Object.fromEntries(analyticEvent + .filter((e) => !this.listeningEvents[e]) + .map((e) => [e, this.nativeListen(e)]) + ) + }; + } + }); + + effect(() => { + if (!this.isTrackingActive()) { + this.unlisten(); + } + }); + } +} diff --git a/packages/@o3r/analytics/src/tracker/directives/focus-event.directive.ts b/packages/@o3r/analytics/src/tracker/directives/focus-event.directive.ts new file mode 100644 index 0000000000..00a3ac46b7 --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/directives/focus-event.directive.ts @@ -0,0 +1,28 @@ +import { Directive, input } from '@angular/core'; +import { AnalyticTrackGeneric } from './generic-event.directive'; + +/** + * Directive to listen and emit analytics in case of Dom Focus event + * @example Focus event on input + * ```html + * + * ``` + */ +@Directive({ + selector: '[trackFocus]', + standalone: true +}) +export class AnalyticTrackFocus extends AnalyticTrackGeneric { + /** @inheritdoc */ + public trackEvent = input(undefined, { alias: 'trackFocus' }); + /** @inheritdoc */ + public trackCategory = input('', { alias: 'trackFocusCategory' }); + /** @inheritdoc */ + public trackAction = input(undefined, { alias: 'trackFocusAction' }); + /** @inheritdoc */ + public trackLabel = input(undefined, { alias: 'trackFocusLabel' }); + /** @inheritdoc */ + public trackValue = input(undefined, { alias: 'trackFocusValue' }); + /** @inheritdoc */ + public readonly eventName = 'focus'; +} diff --git a/packages/@o3r/analytics/src/tracker/directives/generic-event.directive.ts b/packages/@o3r/analytics/src/tracker/directives/generic-event.directive.ts new file mode 100644 index 0000000000..5b61f63291 --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/directives/generic-event.directive.ts @@ -0,0 +1,74 @@ +import { Directive, effect, ElementRef, inject, type OnInit, Renderer2, type Signal } from '@angular/core'; +import { AnalyticsEventReporter } from '../services/tracker/analytics-reporter.service'; +import type { AnalyticsWellKnownDomActionType } from '../events'; + +type TrackEventName = keyof GlobalEventHandlersEventMap; + +@Directive() +/** Generic abstract directive to report Analytics specific UI event */ +export abstract class AnalyticTrackGeneric implements OnInit { + + /** Value of the event to if no {@link trackValue} is specified */ + public abstract trackEvent: Signal; + /** Category of the event */ + public abstract trackCategory: Signal; + /** + * Name of the action as defined in analytics service. + * The name of the Dom Event will be used if not specified. + */ + public abstract trackAction: Signal; + /** Label of the event */ + public abstract trackLabel: Signal; + /** Value of the event */ + public abstract trackValue: Signal; + + /** Name of the Dom Event listen by the directive */ + public abstract readonly eventName: TrackEventName; + + protected readonly el = inject(ElementRef); + protected readonly trackEventsService = inject(AnalyticsEventReporter); + protected readonly renderer = inject(Renderer2); + protected readonly isTrackingActive; + protected listeningEvent?: () => void; + + constructor() { + this.isTrackingActive = this.trackEventsService.isTrackingActive; + } + + /** + * Create the listener for the given event + * @param eventName name of the event to listen + */ + protected nativeListen(eventName: TrackEventName) { + // Renderer is used because it is manipulating the DOM and when the element is destroyed the event listener is destroyed too. + // Usage of an observable from event was not possible because the ngOnDestroy with the unsubscribe was called before the ui event was handled + return this.renderer.listen(this.el.nativeElement, eventName, (event) => { + const action = this.trackAction() || eventName as AnalyticsWellKnownDomActionType; + this.trackEventsService.reportEvent({ + type: 'event', + action, + category: this.trackCategory(), + label: this.trackLabel(), + event, + attributes: this.trackValue() || this.trackEvent() || this.el.nativeElement?.value + }); + }); + } + + /** Remove the created events listeners */ + protected unlisten() { + this.listeningEvent?.(); + this.listeningEvent = undefined; + } + + /** @inheritdoc */ + public ngOnInit(): void { + effect(() => { + if (this.isTrackingActive()) { + this.listeningEvent = this.nativeListen(this.eventName); + } else { + this.unlisten(); + } + }); + } +} diff --git a/packages/@o3r/analytics/src/tracker/directives/index.ts b/packages/@o3r/analytics/src/tracker/directives/index.ts new file mode 100644 index 0000000000..1b9d9f5f6c --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/directives/index.ts @@ -0,0 +1,3 @@ +export * from './click-event.directive'; +export * from './focus-event.directive'; +export * from './events.directive'; diff --git a/packages/@o3r/analytics/src/tracker/events/base.interface.ts b/packages/@o3r/analytics/src/tracker/events/base.interface.ts new file mode 100644 index 0000000000..90f6828d34 --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/events/base.interface.ts @@ -0,0 +1,75 @@ +/** List of the Analytics types available */ +export type AvailableAnalyticsType = 'event' | 'custom'; + +/** + * Custom Action type not handle by the plugin. + * It will passed directly to the different reporter without mapping according to the reporter + */ +export type AnalyticsCustomActionType = `_${string}`; + +/** List of known Dom events */ +export type AnalyticsWellKnownDomActionType = 'focus' | 'click'; + +/** List of known events */ +export type AnalyticsWellKnownActionType = 'pageView' | 'exception' | AnalyticsWellKnownDomActionType; + +/** List of possible actions to be emitted to the reporter */ +export type AnalyticsAvailableActions = AnalyticsWellKnownActionType | AnalyticsCustomActionType; + +interface BaseAnalyticsEvent { + /** type of the Analytics message */ + type: 'event'; + /** Action of the event */ + action: A; + /** Value attributed to the event */ + value?: any; +} + +interface BaseAnalyticsDomEvent extends BaseAnalyticsEvent { + /** Label of the event */ + label?: string; + /** Name of the event to emit */ + event?: any; + /** Category of the event */ + category: string; +} + +/** Click event */ +export type AnalyticsClickEvent = BaseAnalyticsDomEvent<'click'>; + +/** Focus event */ +export type AnalyticsFocusEvent = BaseAnalyticsDomEvent<'focus'>; + +/** Page view event */ +export interface AnalyticsPageViewEvent extends BaseAnalyticsEvent<'pageView'> { + /** Title of the page */ + title?: string; + /** URL of the page */ + location?: string; +} + +/** Exception event */ +export interface AnalyticsExceptionEvent extends BaseAnalyticsEvent<'exception'> { + /** Description of the exception */ + description?: string; + /** Determine if the exception cause a fatal error */ + fatal?: boolean; +} + +/** Generic event */ +export type AnalyticsGenericEvent = BaseAnalyticsEvent; + +/** Available events to be emitted to the reporter */ +export type AnalyticsAvailableEvents = AnalyticsClickEvent + | AnalyticsFocusEvent + | AnalyticsPageViewEvent + | AnalyticsGenericEvent + | AnalyticsExceptionEvent; + +/** Event as reported to the Analytics reporter */ +export interface ReportedEvent { + /** Event to report to third party service */ + report: E; + /** Timestamp of the event */ + reportedAt: number; +} diff --git a/packages/@o3r/analytics/src/tracker/events/index.ts b/packages/@o3r/analytics/src/tracker/events/index.ts new file mode 100644 index 0000000000..946b432272 --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/events/index.ts @@ -0,0 +1 @@ +export * from './base.interface'; diff --git a/packages/@o3r/analytics/src/tracker/index.ts b/packages/@o3r/analytics/src/tracker/index.ts new file mode 100644 index 0000000000..bba204af1f --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/index.ts @@ -0,0 +1,5 @@ +export * from './directives/index'; +export * from './events/index'; +export * from './services/index'; +export * from './logger/index'; +export * from './third-party/index'; diff --git a/packages/@o3r/analytics/src/tracker/logger/index.ts b/packages/@o3r/analytics/src/tracker/logger/index.ts new file mode 100644 index 0000000000..72b9663795 --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/logger/index.ts @@ -0,0 +1 @@ +export * from './logger.analytics'; diff --git a/packages/@o3r/analytics/src/tracker/logger/logger.analytics.spec.ts b/packages/@o3r/analytics/src/tracker/logger/logger.analytics.spec.ts new file mode 100644 index 0000000000..84d00d6f2f --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/logger/logger.analytics.spec.ts @@ -0,0 +1,43 @@ +import { AnalyticsExceptionLogger } from './logger.analytics'; +import type { AnalyticsEventReporter } from '../services/tracker'; + + +describe('Logger Analytics service', () => { + let logger!: AnalyticsExceptionLogger; + let reporter!: AnalyticsEventReporter; + + beforeEach(() => { + reporter = { reportEvent: jest.fn() } as any; + logger = new AnalyticsExceptionLogger(reporter); + }); + + it('should emit warning to reporter', () => { + const message = 'my error'; + logger.warn(message); + expect(reporter.reportEvent).toHaveBeenCalledWith(expect.objectContaining({ + type: 'event', + action: 'exception', + description: message, + fatal: false + })); + }); + + it('should emit error to reporter', () => { + const message = 'my error'; + logger.error(message); + expect(reporter.reportEvent).toHaveBeenCalledWith(expect.objectContaining({ + type: 'event', + action: 'exception', + description: message, + fatal: true + })); + }); + + it('should ignore other messages', () => { + const message = 'my error'; + logger.debug(message); + logger.log(message); + logger.info(message); + expect(reporter.reportEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@o3r/analytics/src/tracker/logger/logger.analytics.ts b/packages/@o3r/analytics/src/tracker/logger/logger.analytics.ts new file mode 100644 index 0000000000..0ae0c6e827 --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/logger/logger.analytics.ts @@ -0,0 +1,74 @@ +/* eslint-disable no-console */ +import type { LoggerClient } from '@o3r/logger'; +import type { AnalyticsEventReporter } from '../services/tracker'; +import type { Action, ActionReducer, MetaReducer } from '@ngrx/store'; + +const ignoredFunction = (..._args: any[]) => {}; + +/** + * Analytics logger to send the warnings and errors to the analytics services + */ +export class AnalyticsExceptionLogger implements LoggerClient { + + /** @inheritdoc */ + public debug = ignoredFunction; + + /** @inheritdoc */ + public info = ignoredFunction; + + /** @inheritdoc */ + public log = ignoredFunction; + + /** @inheritdoc */ + public identify = ignoredFunction; + + /** @inheritdoc */ + public event = ignoredFunction; + + constructor(private readonly reporter: AnalyticsEventReporter) { + } + + /** @inheritdoc */ + public getSessionURL(): string | undefined { + return undefined; + } + + /** @inheritdoc */ + public stopRecording(): void { + + } + + /** @inheritdoc */ + public resumeRecording(): void { + + } + + /** @inheritdoc */ + public createMetaReducer(): MetaReducer> { + return (reducer: ActionReducer): ActionReducer => reducer; + } + + /** @inheritdoc */ + public error(message?: any, ...optionalParams: any[]): void { + this.reporter.reportEvent({ + type: 'event', + action: 'exception', + description: [message, ...optionalParams] + .filter((item) => !!item) + .join('\n'), + fatal: true + }); + } + + /** @inheritdoc */ + public warn(message?: any, ...optionalParams: any[]): void { + this.reporter.reportEvent({ + type: 'event', + action: 'exception', + description: [message, ...optionalParams] + .filter((item) => !!item) + .join('\n'), + fatal: false + }); + } +} diff --git a/packages/@o3r/analytics/src/tracker/services/analytics-tracker.module.ts b/packages/@o3r/analytics/src/tracker/services/analytics-tracker.module.ts new file mode 100644 index 0000000000..9e62c0681f --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/services/analytics-tracker.module.ts @@ -0,0 +1,37 @@ +import { type ModuleWithProviders, NgModule } from '@angular/core'; +import { ANALYTICS_REPORTER_CONFIGURATION, AnalyticsEventReporter, type AnalyticsReporterConfiguration, defaultAnalyticsReporterConfiguration } from './tracker'; +import { AnalyticsRouterTracker } from './router-tracker'; + +/** Configuration of the Analytics tracker nodule */ +export type AnalyticsTrackerModuleOptions = { + /** + * Enable the Router tracker service + * @default false + */ + enableRouterTracker: boolean; + /** + * Configuration of the Analytics Tracker service. Will be merged with the {@link defaultAnalyticsReporterConfiguration} + * @see AnalyticsReporterConfiguration + */ + trackerConfig: Partial; +}; + +@NgModule() +export class AnalyticsTrackerModule { + /** + * Load the Analytics Tracker service on root level + * @param config configuration of the Analytics service + */ + public static forRoot(config?: Partial): ModuleWithProviders { + const configuration = config?.trackerConfig ? { ...defaultAnalyticsReporterConfiguration, ...config.trackerConfig } : defaultAnalyticsReporterConfiguration; + + return { + ngModule: AnalyticsTrackerModule, + providers: [ + { provide: ANALYTICS_REPORTER_CONFIGURATION, useValue: configuration }, + AnalyticsEventReporter, + ...(config?.enableRouterTracker ? [AnalyticsRouterTracker] : []) + ] + }; + } +} diff --git a/packages/@o3r/analytics/src/tracker/services/index.ts b/packages/@o3r/analytics/src/tracker/services/index.ts new file mode 100644 index 0000000000..1d6cddfe66 --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/services/index.ts @@ -0,0 +1,3 @@ +export * from './router-tracker/index'; +export * from './tracker/index'; +export * from './analytics-tracker.module'; diff --git a/packages/@o3r/analytics/src/tracker/services/router-tracker/analytics-router.service.ts b/packages/@o3r/analytics/src/tracker/services/router-tracker/analytics-router.service.ts new file mode 100644 index 0000000000..3c92dc958a --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/services/router-tracker/analytics-router.service.ts @@ -0,0 +1,33 @@ +import { inject, Injectable } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs'; +import { AnalyticsEventReporter } from '../tracker'; +import { Title } from '@angular/platform-browser'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Analytics service reporting the route change events + */ +export class AnalyticsRouterTracker { + private readonly router = inject(Router); + private readonly trackEventsService = inject(AnalyticsEventReporter); + private readonly pageTitle = inject(Title); + + constructor() { + this.router.events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + filter(() => this.trackEventsService.isTrackingActive()), + takeUntilDestroyed() + ).subscribe((event) => this.trackEventsService.reportEvent({ + type: 'event', + action: 'pageView', + value: { + title: this.pageTitle.getTitle(), + location: event.urlAfterRedirects + } + })); + } +} diff --git a/packages/@o3r/analytics/src/tracker/services/router-tracker/analytics-router.servioce.spec.ts b/packages/@o3r/analytics/src/tracker/services/router-tracker/analytics-router.servioce.spec.ts new file mode 100644 index 0000000000..14f5645ebe --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/services/router-tracker/analytics-router.servioce.spec.ts @@ -0,0 +1,79 @@ +import { getTestBed, TestBed } from '@angular/core/testing'; +import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; +import { AnalyticsRouterTracker } from './analytics-router.service'; +import { NavigationEnd, NavigationStart, Router } from '@angular/router'; +import { AnalyticsEventReporter } from '../tracker'; +import { Title } from '@angular/platform-browser'; +import { Subject } from 'rxjs'; + +describe('Analytics router service', () => { + let service: AnalyticsRouterTracker; + let routerEvent: Subject; + let getTitle: () => string; + let isTrackingActive: () => boolean; + let reportEvent: jest.Mock; + + beforeAll(() => getTestBed().platform || TestBed.initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { + teardown: { destroyAfterEach: false } + })); + + beforeEach(async () => { + routerEvent = new Subject(); + getTitle = jest.fn().mockImplementation(() => 'test title'); + reportEvent = jest.fn(); + isTrackingActive = () => true; + await TestBed.configureTestingModule({ + providers: [ + { + provide: Router, + useFactory: () => { + return { events: routerEvent }; + } + }, + { + provide: AnalyticsEventReporter, + useFactory: () => { + return { isTrackingActive, reportEvent }; + } + }, + { + provide: Title, + useFactory: () => { + return { getTitle }; + } + } + ] + }).compileComponents(); + service = TestBed.inject(AnalyticsRouterTracker); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should ignore non-interesting events', () => { + routerEvent.next(new NavigationStart(0,'test')); + expect(service).toBeDefined(); + expect(reportEvent).not.toHaveBeenCalled(); + }); + + it('should emit correct event', () => { + routerEvent.next(new NavigationEnd(0, 'test/url/start', 'test/url/final')); + expect(service).toBeDefined(); + expect(reportEvent).toHaveBeenCalledWith(expect.objectContaining({ + type: 'event', + action: 'pageView' + })); + }); + + it('should give final url as value', () => { + routerEvent.next(new NavigationEnd(0, 'test/url/start', 'test/url/final')); + expect(service).toBeDefined(); + expect(reportEvent).toHaveBeenCalledWith(expect.objectContaining({ + value: expect.objectContaining({ + title: 'test title', + location: 'test/url/final' + }) + })); + }); +}); diff --git a/packages/@o3r/analytics/src/tracker/services/router-tracker/index.ts b/packages/@o3r/analytics/src/tracker/services/router-tracker/index.ts new file mode 100644 index 0000000000..fb3eaeaa5d --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/services/router-tracker/index.ts @@ -0,0 +1 @@ +export * from './analytics-router.service'; diff --git a/packages/@o3r/analytics/src/tracker/services/tracker/analytics-reporter.configuration.ts b/packages/@o3r/analytics/src/tracker/services/tracker/analytics-reporter.configuration.ts new file mode 100644 index 0000000000..9331669edd --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/services/tracker/analytics-reporter.configuration.ts @@ -0,0 +1,21 @@ +import { InjectionToken } from '@angular/core'; +import type { AnalyticsThirdPartyService } from '../../third-party'; + +/** Configuration of the Analytics Reporter */ +export interface AnalyticsReporterConfiguration { + /** Size of the event stack to keep when no Analytics service registered */ + eventStackSize: number; + /** Determine if the service is collecting analytics on the load of the service */ + activatedOnBootstrap: boolean; + /** List of Analytics services to register on bootstrap of the service */ + registeredAnalyticsServicesOnBootstrap?: AnalyticsThirdPartyService[]; +} + +/** Default configuration */ +export const defaultAnalyticsReporterConfiguration: AnalyticsReporterConfiguration = { + eventStackSize: 10, + activatedOnBootstrap: true +}; + +/** Token to inject configuration to the Analytics reporter */ +export const ANALYTICS_REPORTER_CONFIGURATION = new InjectionToken('Configuration for the Analytics Tracker', { factory: () => defaultAnalyticsReporterConfiguration }); diff --git a/packages/@o3r/analytics/src/tracker/services/tracker/analytics-reporter.service.ts b/packages/@o3r/analytics/src/tracker/services/tracker/analytics-reporter.service.ts new file mode 100644 index 0000000000..46e67ba572 --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/services/tracker/analytics-reporter.service.ts @@ -0,0 +1,100 @@ +import { effect, inject, Inject, Injectable, signal, untracked } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ReplaySubject } from 'rxjs'; +import { ANALYTICS_REPORTER_CONFIGURATION, type AnalyticsReporterConfiguration } from './analytics-reporter.configuration'; +import type { AnalyticsHookOptions, AnalyticsThirdPartyService } from '../../third-party/analytics-third-party.interfaces'; +import type { AnalyticsAvailableEvents, ReportedEvent } from '../../events/base.interface'; +import { LoggerService } from '@o3r/logger'; + +@Injectable({ + providedIn: 'root' +}) +/** Analytics Event Reporter */ +export class AnalyticsEventReporter { + private readonly stack; + private readonly services; + private readonly hookOptions: AnalyticsHookOptions = { + logger: inject(LoggerService, {optional: true}) ?? undefined + }; + + /** Stream of the events reported to the Analytics service */ + public readonly events$; + /** Determine and define if the tracking service is activated and if the captured events should be emitted to the registered Analytics services */ + public readonly isTrackingActive; + /** List if of the Analytics services to report the events too */ + public readonly analyticsServices; + + constructor(@Inject(ANALYTICS_REPORTER_CONFIGURATION) readonly config: AnalyticsReporterConfiguration) { + this.stack = new ReplaySubject(config.eventStackSize); + this.isTrackingActive = signal(config.activatedOnBootstrap); + this.services = signal(config.registeredAnalyticsServicesOnBootstrap || []); + + this.analyticsServices = this.services.asReadonly(); + this.events$ = this.stack.asObservable().pipe( + takeUntilDestroyed() + ); + + effect(async () => { + const isTrackingActive = this.isTrackingActive(); + await untracked(() => Promise.all(isTrackingActive ? + this.analyticsServices() + .filter((service) => !!service.onActivation) + .map((service) => service.onActivation!(this.hookOptions)) : + this.analyticsServices() + .filter((service) => !!service.onDeactivation) + .map((service) => service.onDeactivation!(this.hookOptions)) + )); + }); + } + + private reportAnalytics(report: T) { + this.stack.next({ + reportedAt: Date.now(), + report + }); + } + + /** + * Register a third party Analytics service to report events to + * @param services Third party Analytics service + */ + public async registerAnalyticsServices(services: AnalyticsThirdPartyService | AnalyticsThirdPartyService[]) { + const serviceList = Array.isArray(services) ? services : [services]; + + await Promise.all( + serviceList + .filter((service) => !!service.onRegistration) + .map((service) => service.onRegistration!()) + ); + + this.services.update((analyticsServices) => [ + ...analyticsServices, + ...serviceList + ]); + + serviceList + .forEach((service) => this.events$ + .pipe(takeUntilDestroyed()) + .subscribe((event) => service.emit(event, this.hookOptions)) + ); + + if (this.isTrackingActive()) { + await Promise.all( + serviceList + .filter((service) => !!service.onActivation) + .map((service) => service.onActivation!(this.hookOptions)) + ); + } + } + + /** + * Report an Event to the Analytics services + * @param event Event to emit the the Analytics services + * @param enforceStorage Determine if the event should be stored in the stack even is if the service is not activated\ + */ + public reportEvent(event: T, enforceStorage = false) { + if (enforceStorage || this.isTrackingActive()) { + this.reportAnalytics(event); + } + } +} diff --git a/packages/@o3r/analytics/src/tracker/services/tracker/index.ts b/packages/@o3r/analytics/src/tracker/services/tracker/index.ts new file mode 100644 index 0000000000..c7768c760b --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/services/tracker/index.ts @@ -0,0 +1,2 @@ +export * from './analytics-reporter.configuration'; +export * from './analytics-reporter.service'; diff --git a/packages/@o3r/analytics/src/tracker/third-party/analytics-third-party.interfaces.ts b/packages/@o3r/analytics/src/tracker/third-party/analytics-third-party.interfaces.ts new file mode 100644 index 0000000000..d048f73fc4 --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/third-party/analytics-third-party.interfaces.ts @@ -0,0 +1,20 @@ +import type { ReportedEvent } from '../events'; +import type { Logger } from '@o3r/core'; + +/** Common options provided to the hooks */ +export interface AnalyticsHookOptions { + /** Logger */ + logger?: Logger; +} + +/** Analytics Third Party service to register to the {@link AnalyticsEventReporter} */ +export interface AnalyticsThirdPartyService { + /** Hook called when the services has been registered to the {@link AnalyticsEventReporter} */ + onRegistration?: (options?: Partial) => void | Promise; + /** Hook called when the the {@link AnalyticsEventReporter} ios activated. */ + onActivation?: (options?: Partial) => void | Promise; + /** Hook called when the the {@link AnalyticsEventReporter} ios deactivated. */ + onDeactivation?: (options?: Partial) => void | Promise; + /** Hook called by the {@link AnalyticsEventReporter} to emit an Analytics Event to the service implemented this interface */ + emit: (analyticsItem: ReportedEvent, options?: Partial) => void | Promise; +} diff --git a/packages/@o3r/analytics/src/tracker/third-party/google-analytics/google-analytics.analytics.spec.ts b/packages/@o3r/analytics/src/tracker/third-party/google-analytics/google-analytics.analytics.spec.ts new file mode 100644 index 0000000000..85c5f9ff9b --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/third-party/google-analytics/google-analytics.analytics.spec.ts @@ -0,0 +1,88 @@ +import type { AnalyticsThirdPartyService } from '../analytics-third-party.interfaces'; +import { createGoogleAnalyticsService } from './google-analytics.analytics'; + +const uuid = 'my-ID'; + +describe('Google Analytics handler', () => { + let gaService!: AnalyticsThirdPartyService; + + beforeEach(() => gaService = createGoogleAnalyticsService({ uuid })); + + it('should get instantiated', () => { + expect(gaService).toBeDefined(); + expect(gaService).toEqual(expect.objectContaining({ + onRegistration: expect.any(Function), + emit: expect.any(Function) + })); + }); + + it('should register data-layer and initialize config', async () => { + const arr: any[] = []; + window.dataLayer = arr; + + await gaService.onRegistration(); + expect(arr).toContainEqual(['js', expect.any(Date)]); + expect(arr).toContainEqual(['config', uuid]); + }); + + it('should handle known action', async () => { + const arr: any[] = []; + window.dataLayer = arr; + + await gaService.onRegistration(); + await gaService.emit({ + reportedAt: 0, + report: { + type: 'event', + action: 'pageView', + location: 'test-location', + title: 'test-title' + } + }); + + expect(arr).toContainEqual(['event', 'page_view', { + // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase + page_location: 'test-location', + // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase + page_title: 'test-title' + }]); + }); + + it('should handle custom actions', async () => { + const arr: any[] = []; + window.dataLayer = arr; + + await gaService.onRegistration(); + await gaService.emit({ + reportedAt: 0, + report: { + type: 'event', + action: '_myAction', + value: {test: true} + } + }); + + expect(arr).toContainEqual(['event', 'myAction', { test: true }]); + }); + + it('should warn non-handle events', async () => { + const arr: any[] = []; + const logger: any = { + warn: jest.fn() + }; + window.dataLayer = arr; + + await gaService.onRegistration(); + await gaService.emit({ + reportedAt: 0, + report: { + type: 'event', + action: 'myAction', + value: { test: true } + } as any + }, {logger}); + + expect(arr).not.toContainEqual(['event', 'myAction', { test: true }]); + expect(logger.warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/@o3r/analytics/src/tracker/third-party/google-analytics/google-analytics.analytics.ts b/packages/@o3r/analytics/src/tracker/third-party/google-analytics/google-analytics.analytics.ts new file mode 100644 index 0000000000..0fded0062e --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/third-party/google-analytics/google-analytics.analytics.ts @@ -0,0 +1,88 @@ +import type { AnalyticsThirdPartyService } from '../analytics-third-party.interfaces'; + +type GoogleAnalyticsServiceOptions = { + uuid: string; +}; + +declare global { + interface Window { + gtag: (...args: any[]) => void; + dataLayer?: any[]; + } +} + +/** + * Create a Google Analytics Service handler + * @param options.uuid UUID of the Google Analytics Account identifying the application + */ +export function createGoogleAnalyticsService({ uuid }: GoogleAnalyticsServiceOptions): AnalyticsThirdPartyService { + let dataLayer: any[] = []; + let gtag!: (cmd: string, action: any, data?: any) => void; + return { + onRegistration: () => { + const s = document.createElement('script'); + s.setAttribute('src', `https://www.googletagmanager.com/gtag/js?id=${uuid}`); + s.async = true; + document.head.appendChild(s); + + dataLayer = window.dataLayer || dataLayer; + // eslint-disable-next-line prefer-arrow/prefer-arrow-functions + gtag = function (...args: any[]) { dataLayer.push(args); }; + gtag('js', new Date()); + gtag('config', uuid); + }, + + emit: (analyticsReport, options) => { + const analyticsItem = analyticsReport.report; + switch (analyticsItem.type) { + case 'event': { + /* eslint-disable @typescript-eslint/naming-convention, camelcase */ + switch (analyticsItem.action) { + case 'click': + case 'focus': { + gtag('event', analyticsItem.action, { + event_category: analyticsItem.category, + event_label: analyticsItem.label, + value: analyticsItem.value + }); + break; + } + + case 'pageView': { + gtag('event', 'page_view', { + page_title: analyticsItem.title, + page_location: analyticsItem.location + }); + break; + } + + case 'exception': { + gtag('event', 'exception', { + description: analyticsItem.description, + fatal: analyticsItem.fatal ?? false + }); + break; + } + + default: { + if (!analyticsItem.action.startsWith('_')) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + return options?.logger?.warn(`Google Analytics reporter received an event with the action "${analyticsItem.action}" which is not supported`, analyticsItem); + } + + gtag('event', analyticsItem.action.replace(/^_/, ''), analyticsItem.value); + } + } + /* eslint-enable @typescript-eslint/naming-convention, camelcase */ + break; + } + + default: { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + options?.logger?.warn(`Google Analytics reporter received a message typed "${analyticsItem.type}" which is not supported`, analyticsItem); + } + } + } + + }; +} diff --git a/packages/@o3r/analytics/src/tracker/third-party/index.ts b/packages/@o3r/analytics/src/tracker/third-party/index.ts new file mode 100644 index 0000000000..1009d29f67 --- /dev/null +++ b/packages/@o3r/analytics/src/tracker/third-party/index.ts @@ -0,0 +1,2 @@ +export * from './analytics-third-party.interfaces'; +export * from './google-analytics/google-analytics.analytics'; diff --git a/packages/@o3r/logger/README.md b/packages/@o3r/logger/README.md index df6ea2d465..06e0508973 100644 --- a/packages/@o3r/logger/README.md +++ b/packages/@o3r/logger/README.md @@ -57,7 +57,7 @@ The store can also be bound to the third-party logging service by using the `Log // in app.module.ts import {Action, MetaReducer, USER_PROVIDED_META_REDUCERS} from '@ngrx/store'; -import {LoggerServuce} from '@o3r/logger'; +import {LoggerService} from '@o3r/logger'; // ... diff --git a/packages/@o3r/logger/src/services/logger/logger.console.ts b/packages/@o3r/logger/src/services/logger/logger.console.ts index 1090f8310e..997705e56f 100644 --- a/packages/@o3r/logger/src/services/logger/logger.console.ts +++ b/packages/@o3r/logger/src/services/logger/logger.console.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; +import type { Action, ActionReducer, MetaReducer } from '@ngrx/store'; import type { LoggerClient } from './logger.client'; /** diff --git a/yarn.lock b/yarn.lock index becbcc64f4..766be9a2b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6228,6 +6228,7 @@ __metadata: "@o3r/build-helpers": "workspace:^" "@o3r/core": "workspace:^" "@o3r/eslint-plugin": "workspace:^" + "@o3r/logger": "workspace:^" "@o3r/test-helpers": "workspace:^" "@schematics/angular": "npm:~18.1.0" "@stylistic/eslint-plugin-ts": "npm:~2.4.0" @@ -6269,12 +6270,15 @@ __metadata: "@angular/router": ~18.1.0 "@ngrx/store": ~18.0.0 "@o3r/core": "workspace:^" + "@o3r/logger": "workspace:^" "@o3r/schematics": "workspace:^" "@schematics/angular": ~18.1.0 jasmine: ^5.0.0 rxjs: ^7.8.1 webpack: ~5.93.0 peerDependenciesMeta: + "@o3r/logger": + optional: true "@o3r/schematics": optional: true "@schematics/angular":