Skip to content

Commit 5998627

Browse files
committed
feat: integrate penguin analytics tracking
- Add analytics.service.ts wrapper around analytics.io library - Add penguin-analytics-plugin.ts for backend communication - Track page views, link clicks, button clicks, user activity - Add getUserGuid() and getPreferredUsername() to KeycloakService - Fix Keycloak reload loop (checkLoginIframe: false, URL hash cleanup) - Add analytics npm package dependency - Add proxy.conf.json for local dev (proxies to localhost:3000) - Add proxyConfig to angular.json for local development
1 parent d3f6320 commit 5998627

File tree

13 files changed

+594
-43
lines changed

13 files changed

+594
-43
lines changed

.yarn/install-state.gz

7.64 KB
Binary file not shown.

angular.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@
9090
"builder": "@angular/build:dev-server",
9191
"options": {
9292
"port": 4200,
93-
"buildTarget": "base-app2:build:development"
93+
"buildTarget": "base-app2:build:development",
94+
"proxyConfig": "proxy.conf.json"
9495
},
9596
"configurations": {
9697
"production": {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@ng-select/ng-select": "^21.1.0",
3030
"@popperjs/core": "^2.11.8",
3131
"@tinymce/tinymce-angular": "^9.0.0",
32+
"analytics": "^0.8.14",
3233
"bootstrap": "^5.3.7",
3334
"jszip": "^3.10.1",
3435
"keycloak-angular": "^21.0.0",

proxy.conf.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"/api/analytics": {
3+
"target": "http://localhost:3000",
4+
"secure": false,
5+
"changeOrigin": true,
6+
"pathRewrite": {
7+
"^/api/analytics": "/events"
8+
},
9+
"logLevel": "debug"
10+
}
11+
}

src/app/app.component.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { Component, OnInit, HostBinding, inject } from '@angular/core';
22

3-
import { RouterModule } from '@angular/router';
3+
import { Router, NavigationEnd, RouterModule } from '@angular/router';
4+
import { filter } from 'rxjs/operators';
45
import { HeaderComponent } from './header/header.component';
56
import { SidebarComponent } from './sidebar/sidebar.component';
67
import { ToggleButtonComponent } from './toggle-button/toggle-button.component';
78
import { FooterComponent } from './footer/footer.component';
89
import { SideBarService } from './services/sidebar.service';
10+
import { AnalyticsService } from './services/analytics/analytics.service';
11+
import { KeycloakService } from './services/keycloak.service';
912

1013
@Component({
1114
selector: 'app-root',
@@ -23,7 +26,9 @@ import { SideBarService } from './services/sidebar.service';
2326

2427
export class AppComponent implements OnInit {
2528
private sideBarService = inject(SideBarService);
26-
29+
private analyticsService = inject(AnalyticsService);
30+
private keycloakService = inject(KeycloakService);
31+
private router = inject(Router);
2732

2833
@HostBinding('class.sidebarcontrol')
2934
isOpen = false;
@@ -32,5 +37,44 @@ export class AppComponent implements OnInit {
3237
this.sideBarService.toggleChange.subscribe(isOpen => {
3338
this.isOpen = isOpen;
3439
});
40+
41+
// Identify user for analytics if authenticated
42+
// This MUST be called before any page tracking to ensure userId is set
43+
if (this.keycloakService.isAuthenticated()) {
44+
const userGuid = this.keycloakService.getUserGuid();
45+
if (userGuid) {
46+
const sessionStart = new Date().toISOString();
47+
this.analyticsService.identify(userGuid, {
48+
username: this.keycloakService.getPreferredUsername(),
49+
roles: this.keycloakService.getUserRoles(),
50+
session_start: sessionStart,
51+
auth_provider: this.keycloakService.getIdpFromToken() || 'unknown'
52+
});
53+
}
54+
}
55+
56+
// Track page views on navigation
57+
// These will only be sent AFTER identify() is called
58+
this.router.events
59+
.pipe(filter(event => event instanceof NavigationEnd))
60+
.subscribe((event: NavigationEnd) => {
61+
const routePath = event.urlAfterRedirects || event.url;
62+
const pageName = this.getPageName(routePath);
63+
this.analyticsService.page(pageName, { path: routePath });
64+
});
65+
}
66+
67+
/** Extract page name from URL path */
68+
private getPageName(path: string): string {
69+
const cleanPath = path.split('?')[0].split('#')[0].split(';')[0];
70+
const routePath = cleanPath.replace(/^\/admin\/?/, '');
71+
72+
if (!routePath || routePath === '/') return 'Home';
73+
74+
return routePath
75+
.split('/')
76+
.filter(s => s && !s.match(/^[0-9a-f-]{20,}$/i)) // Remove IDs
77+
.map(s => s.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()))
78+
.join(' > ') || 'Home';
3579
}
3680
}
Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
@for (item of items; track item) {
2-
<tr (click)="itemClicked(item)">
3-
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[0].width">{{item.type || '-'}}</td>
4-
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[1].width">{{item.appliedTo || '-'}}</td>
5-
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[2].width">{{item.start | date: 'longDate'}}</td>
6-
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[3].width">{{item.end | date: 'longDate'}}</td>
7-
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[4].width"><a href="javascript:void(0);" (click)="goToItem(item)">View</a></td>
8-
</tr>
2+
<tr (click)="itemClicked(item)">
3+
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[0].width">{{item.type || '-'}}</td>
4+
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[1].width">{{item.appliedTo || '-'}}</td>
5+
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[2].width">{{item.start | date: 'longDate'}}</td>
6+
<td scope="row" data-label="Name" [style.width]="!useSmallTable && columns[3].width">{{item.end | date: 'longDate'}}</td>
7+
</tr>
98
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Injectable } from '@angular/core';
2+
import Analytics from 'analytics';
3+
import type { AnalyticsInstance } from 'analytics';
4+
import { penguinAnalyticsPlugin } from './penguin-analytics-plugin';
5+
6+
interface EnvConfig {
7+
ANALYTICS_API_URL?: string;
8+
ANALYTICS_DEBUG?: boolean;
9+
API_LOCATION?: string;
10+
API_PATH?: string;
11+
}
12+
13+
declare const window: Window & { __env?: EnvConfig };
14+
15+
const buildDefaultAnalyticsUrl = (env?: EnvConfig): string => {
16+
const base = env?.API_LOCATION?.replace(/\/$/, '') || '';
17+
const apiPath = env?.API_PATH || '/api';
18+
const normalizedPath = apiPath.startsWith('/') ? apiPath : `/${apiPath}`;
19+
return base ? `${base}${normalizedPath}/telemetry` : `${normalizedPath}/telemetry`;
20+
};
21+
22+
/**
23+
* Analytics service using Analytics.io with Penguin Analytics plugin.
24+
*
25+
* ## Auto-tracked events (no code needed):
26+
* - Page views (on route changes)
27+
* - Link clicks
28+
* - Button clicks
29+
* - User activity pings
30+
*
31+
* ## Manual tracking:
32+
* Use "Object + Past Verb" naming: "Form Submitted", "Document Downloaded"
33+
*
34+
* @example
35+
* ```typescript
36+
* // Page view (auto-tracked, but can override)
37+
* analytics.page('Project Details', { project_id: '123' });
38+
*
39+
* // Custom event
40+
* analytics.track('Report Generated', { format: 'pdf' });
41+
*
42+
* // Identify user after login
43+
* analytics.identify(userId, { username: 'john', roles: ['admin'] });
44+
* ```
45+
*/
46+
@Injectable({
47+
providedIn: 'root'
48+
})
49+
export class AnalyticsService {
50+
private analytics: AnalyticsInstance;
51+
private initialized = false;
52+
53+
constructor() {
54+
const env = window.__env;
55+
const apiUrl = env?.ANALYTICS_API_URL || buildDefaultAnalyticsUrl(env);
56+
const debug = env?.ANALYTICS_DEBUG ?? false;
57+
58+
this.analytics = Analytics({
59+
app: 'eagle-admin',
60+
debug: debug,
61+
plugins: [
62+
penguinAnalyticsPlugin({
63+
apiUrl: apiUrl,
64+
sourceApp: 'eagle-admin',
65+
debug: debug
66+
})
67+
]
68+
});
69+
70+
this.initialized = true;
71+
}
72+
73+
/** Track a page view */
74+
page(name?: string, properties?: Record<string, any>): void {
75+
if (!this.initialized) return;
76+
this.analytics.page({ name, ...properties });
77+
}
78+
79+
/** Track a custom event. Use "Object + Past Verb" naming. */
80+
track(event: string, properties?: Record<string, any>): void {
81+
if (!this.initialized) return;
82+
this.analytics.track(event, properties);
83+
}
84+
85+
/** Identify user after authentication */
86+
identify(userId: string, traits?: Record<string, any>): void {
87+
if (!this.initialized) return;
88+
this.analytics.identify(userId, traits);
89+
}
90+
91+
/** Reset on logout */
92+
reset(): void {
93+
if (!this.initialized) return;
94+
this.analytics.reset();
95+
}
96+
}

0 commit comments

Comments
 (0)