diff --git a/src/app/components/builder-tabs/builder-tabs.component.ts b/src/app/components/builder-tabs/builder-tabs.component.ts index 7fc61d08..5bc54acf 100644 --- a/src/app/components/builder-tabs/builder-tabs.component.ts +++ b/src/app/components/builder-tabs/builder-tabs.component.ts @@ -26,6 +26,7 @@ import { MatIcon } from '@angular/material/icon'; import { MatInput } from '@angular/material/input'; import { MatOption, MatSelect } from '@angular/material/select'; import { SnackbarService } from '../../core/services/snackbar.service'; +import { AnalyticsService } from '../../core/services/analytics.service'; import {MatExpansionModule} from '@angular/material/expansion'; import { MatTooltip } from '@angular/material/tooltip'; import { Router } from '@angular/router'; @@ -129,6 +130,7 @@ export class BuilderTabsComponent { private snackBar = inject(SnackbarService); private router = inject(Router); private cdr = inject(ChangeDetectorRef); + private analyticsService = inject(AnalyticsService); protected selectedTool: ToolNode | undefined = undefined; protected toolAgentName: string = ''; @@ -802,6 +804,7 @@ export class BuilderTabsComponent { if (success) { this.agentService.agentBuild(appName, formData).subscribe((success) => { if (success) { + this.analyticsService.sendEvent('builder_agent_save_click'); this.router.navigate(['/'], { queryParams: { app: appName } }).then(() => { diff --git a/src/app/components/chat/chat.component.html b/src/app/components/chat/chat.component.html index 5f470736..7bbfa872 100644 --- a/src/app/components/chat/chat.component.html +++ b/src/app/components/chat/chat.component.html @@ -200,6 +200,19 @@ (save)="saveUserId($event)" > +
+ Usage Metrics + + info_outline + + + + +
diff --git a/src/app/components/chat/chat.component.scss b/src/app/components/chat/chat.component.scss index b827318c..8d249add 100644 --- a/src/app/components/chat/chat.component.scss +++ b/src/app/components/chat/chat.component.scss @@ -558,6 +558,27 @@ gap: 4px; } +.user-menu-telemetry-row { + display: flex; + align-items: center; + margin-top: 16px; + gap: 6px; + + .telemetry-label { + font-size: 14px; + font-weight: 500; + color: var(--chat-toolbar-session-text-color); + } + + .info-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: var(--chat-toolbar-icon-color); + cursor: help; + } +} + .user-menu-id { font-family: 'Google Sans Mono', monospace; font-size: 14px; diff --git a/src/app/components/chat/chat.component.spec.ts b/src/app/components/chat/chat.component.spec.ts index 28d04129..2d31d825 100644 --- a/src/app/components/chat/chat.component.spec.ts +++ b/src/app/components/chat/chat.component.spec.ts @@ -76,6 +76,9 @@ import {MockMarkdownComponent} from '../markdown/testing/mock-markdown.component import {SidePanelComponent} from '../side-panel/side-panel.component'; import {THEME_SERVICE} from '../../core/services/interfaces/theme'; import {MockThemeService} from '../../core/services/testing/mock-theme.service'; +import {TelemetryService} from '../../core/services/telemetry.service'; +import {TelemetryConsentDialogComponent} from '../telemetry-consent-dialog/telemetry-consent-dialog.component'; +import {computed, signal} from '@angular/core'; import {ChatComponent, HIDE_SIDE_PANEL_QUERY_PARAM, INITIAL_USER_INPUT_QUERY_PARAM} from './chat.component'; @@ -128,9 +131,21 @@ const A2A_DATA_PART_TAG_START = ''; const A2A_DATA_PART_TAG_END = ''; const A2UI_MIME_TYPE = 'application/json+a2ui'; +class MockTelemetryService { + readonly telemetryStatus = signal(undefined); + readonly telemetryEnabled = computed(() => this.telemetryStatus() === true); + fetchTelemetryStatus = jasmine.createSpy('fetchTelemetryStatus').and.callFake(async () => { + return this.telemetryStatus(); + }); + setTelemetry = jasmine.createSpy('setTelemetry').and.callFake(async (enabled: boolean) => { + this.telemetryStatus.set(enabled); + }); +} + describe('ChatComponent', () => { let component: ChatComponent; let fixture: ComponentFixture; + let mockTelemetryService: MockTelemetryService; let mockSessionService: MockSessionService; let mockArtifactService: MockArtifactService; let mockWebSocketService: MockWebSocketService; @@ -156,6 +171,7 @@ describe('ChatComponent', () => { let mockAgentBuilderService: jasmine.SpyObj; beforeEach(async () => { + mockTelemetryService = new MockTelemetryService(); mockSessionService = new MockSessionService(); mockArtifactService = new MockArtifactService(); mockWebSocketService = new MockWebSocketService(); @@ -278,6 +294,7 @@ describe('ChatComponent', () => { {provide: UI_STATE_SERVICE, useValue: mockUiStateService}, {provide: ErrorHandler, useValue: mockErrorHandler}, {provide: AGENT_BUILDER_SERVICE, useValue: mockAgentBuilderService}, + {provide: TelemetryService, useValue: mockTelemetryService}, {provide: THEME_SERVICE, useClass: MockThemeService}, ], }) @@ -1859,5 +1876,51 @@ describe('ChatComponent', () => { expect(component.uiEvents()[0].text).toBe('final answer'); }); }); + + describe('Telemetry Integration', () => { + it('should open telemetry consent dialog when telemetryStatus is null on init', fakeAsync(() => { + mockTelemetryService.telemetryStatus.set(null); + + component.ngOnInit(); + tick(); + + expect(mockDialog.open).toHaveBeenCalledWith( + TelemetryConsentDialogComponent, + jasmine.objectContaining({ disableClose: true }) + ); + })); + + it('should not open telemetry consent dialog when telemetryStatus is true on init', fakeAsync(() => { + mockTelemetryService.telemetryStatus.set(true); + + component.ngOnInit(); + tick(); + + expect(mockDialog.open).not.toHaveBeenCalledWith( + TelemetryConsentDialogComponent, + jasmine.any(Object) + ); + })); + + it('should not open telemetry consent dialog when telemetryStatus is false on init', fakeAsync(() => { + mockTelemetryService.telemetryStatus.set(false); + + component.ngOnInit(); + tick(); + + expect(mockDialog.open).not.toHaveBeenCalledWith( + TelemetryConsentDialogComponent, + jasmine.any(Object) + ); + })); + + it('should call setTelemetry when onTelemetryToggle is called', async () => { + await component.onTelemetryToggle(true); + expect(mockTelemetryService.setTelemetry).toHaveBeenCalledWith(true); + + await component.onTelemetryToggle(false); + expect(mockTelemetryService.setTelemetry).toHaveBeenCalledWith(false); + }); + }); }); }); diff --git a/src/app/components/chat/chat.component.ts b/src/app/components/chat/chat.component.ts index 1b3ba897..2090bd40 100644 --- a/src/app/components/chat/chat.component.ts +++ b/src/app/components/chat/chat.component.ts @@ -69,6 +69,7 @@ import { ListResponse } from '../../core/services/interfaces/types'; import { UI_STATE_SERVICE } from '../../core/services/interfaces/ui-state'; import { LOCATION_SERVICE } from '../../core/services/location.service'; import { TestsService } from '../../core/services/tests.service'; +import { AnalyticsService } from '../../core/services/analytics.service'; import { ResizableDrawerDirective } from '../../directives/resizable-drawer.directive'; import { AddItemDialogComponent } from '../add-item-dialog/add-item-dialog.component'; import { AgentStructureGraphDialogComponent } from '../agent-structure-graph-dialog/agent-structure-graph-dialog'; @@ -84,6 +85,9 @@ import { SidePanelComponent } from '../side-panel/side-panel.component'; import { ViewImageDialogComponent } from '../view-image-dialog/view-image-dialog.component'; import { InlineEditComponent } from '../inline-edit/inline-edit.component'; import { FormatMetricNamePipe } from '../eval-tab/format-metric-name.pipe'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { TelemetryService } from '../../core/services/telemetry.service'; +import { TelemetryConsentDialogComponent } from '../telemetry-consent-dialog/telemetry-consent-dialog.component'; import { ChatMessagesInjectionToken } from './chat.component.i18n'; import { SidePanelMessagesInjectionToken } from '../side-panel/side-panel.component.i18n'; @@ -181,6 +185,8 @@ const BIDI_STREAMING_IN_PROGRESS_WARNING = SessionTabComponent, InlineEditComponent, FormatMetricNamePipe, + MatSlideToggleModule, + TelemetryConsentDialogComponent, ], }) export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { @@ -214,6 +220,8 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { protected readonly uiStateService = inject(UI_STATE_SERVICE); protected readonly agentBuilderService = inject(AGENT_BUILDER_SERVICE); protected readonly themeService = inject(THEME_SERVICE, { optional: true }); + protected readonly telemetryService = inject(TelemetryService); + protected readonly analyticsService = inject(AnalyticsService); protected readonly logoComponent: Type | null = inject(LOGO_COMPONENT, { optional: true, }); @@ -761,6 +769,10 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.agentService.getVersion().subscribe((res) => { this.adkVersion.set(res.version || ''); this.versionInfo.set(res); + this.analyticsService.setUserProperties({ + adk_version: res?.version || '', + adk_language: res?.language || '', + }); }); combineLatest([ @@ -860,6 +872,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { }); } }); + this.checkTelemetryConsent(); } get sessionTab() { @@ -870,6 +883,20 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.onViewModeChange('traces'); } + private async checkTelemetryConsent(): Promise { + const consent = await this.telemetryService.fetchTelemetryStatus(); + if (consent === null) { + this.dialog.open(TelemetryConsentDialogComponent, { + disableClose: true, + panelClass: 'telemetry-consent-dialog-panel', + }); + } + } + + async onTelemetryToggle(checked: boolean): Promise { + await this.telemetryService.setTelemetry(checked); + } + ngAfterViewInit() { if (this.showSidePanel) { this.sideDrawer()?.open(); @@ -1084,6 +1111,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.sessionId = res.id ?? ''; this.sessionTab?.refreshSession(); this.sessionTab?.reloadSession(this.sessionId); + this.analyticsService.sendEvent('chat_session_create'); this.isSessionUrlEnabledObs.subscribe((enabled) => { if (enabled) { @@ -3085,6 +3113,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { .toString(); this.location.replaceState(url); this.isBuilderMode.set(true); + this.analyticsService.sendEvent('builder_enter_click'); // Load existing agent configuration if app is selected if (this.appName) { @@ -3146,6 +3175,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { openAgentStructureGraphDialog(mode: 'session' | 'event' = 'session'): void { this.agentStructureOverlayMode = mode; this.showAgentStructureOverlay = true; + this.analyticsService.sendEvent('graph_view_click'); } saveAgentBuilder() { diff --git a/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.ts b/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.ts index 8707b81c..c2c7a041 100644 --- a/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.ts +++ b/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.ts @@ -19,6 +19,7 @@ import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef, MatDialogTitle, MatDialogContent, MatDialogActions, MatDialogClose } from '@angular/material/dialog'; import {uuidv4} from 'uuidv7'; import {EVAL_SERVICE} from '../../../../core/services/interfaces/eval'; +import {AnalyticsService} from '../../../../core/services/analytics.service'; import { CdkScrollable } from '@angular/cdk/scrolling'; import { MatFormField } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; @@ -55,6 +56,7 @@ export class AddEvalSessionDialogComponent { existingCases?: string[]; } = inject(MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); + private readonly analyticsService = inject(AnalyticsService); newCaseId: string = this.data.defaultName || ('case_' + uuidv4().slice(0, 6)); loading = false; @@ -81,6 +83,7 @@ export class AddEvalSessionDialogComponent { ) .subscribe({ next: (res) => { + this.analyticsService.sendEvent('eval_create_click'); this.dialogRef.close(true); }, error: (err) => { diff --git a/src/app/components/side-panel/side-panel.component.ts b/src/app/components/side-panel/side-panel.component.ts index 92145411..dd4a31ae 100644 --- a/src/app/components/side-panel/side-panel.component.ts +++ b/src/app/components/side-panel/side-panel.component.ts @@ -40,6 +40,7 @@ import {TraceTabComponent} from '../trace-tab/trace-tab.component'; import {EventTabComponent} from '../event-tab/event-tab.component'; import {TRACE_SERVICE} from '../../core/services/interfaces/trace'; +import {AnalyticsService} from '../../core/services/analytics.service'; import {SidePanelMessagesInjectionToken} from './side-panel.component.i18n'; /** @@ -119,6 +120,7 @@ export class SidePanelComponent implements AfterViewInit, OnInit { viewChild('evalTabContainer', {read: ViewContainerRef}); readonly tabGroup = viewChild(MatTabGroup); + protected readonly analyticsService = inject(AnalyticsService); readonly logoComponent: Type|null = inject(LOGO_COMPONENT, { optional: true, }); @@ -199,6 +201,12 @@ export class SidePanelComponent implements AfterViewInit, OnInit { onTabChange(event: MatTabChangeEvent) { this.tabChange.emit(event); this.selectedIndex = event.index; + const label = event.tab?.textLabel?.toLowerCase() || ''; + if (label.includes('trace')) { + this.analyticsService.sendEvent('trace_view_toggle'); + } else if (label.includes('event')) { + this.analyticsService.sendEvent('event_view_toggle'); + } } switchToEvalTab() { diff --git a/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.html b/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.html new file mode 100644 index 00000000..b40e628b --- /dev/null +++ b/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.html @@ -0,0 +1,20 @@ +

Help Improve ADK!

+ +

To help us make ADK better, please consider allowing Google to collect pseudonymized usage data from your interactions with both ADK Web UI and CLI. This data helps us understand how features are used, identify areas for improvement, and prioritize development.

+ +
+ What we collect: Usage patterns, performance metrics, and environment details (OS, versions). We do not collect any personal information, code, or agent data. +
+ +
+ Your Choice: This is OFF by default. Your participation is optional. +
+ +
+ Control: You can change this setting at any time via the toggle in the Web UI User Settings or by using the CLI command (adk telemetry disable). +
+
+ + + + diff --git a/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.scss b/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.scss new file mode 100644 index 00000000..dc4677a2 --- /dev/null +++ b/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.scss @@ -0,0 +1,52 @@ +.dialog-title { + font-family: 'Google Sans', 'Google Sans Logo', sans-serif; + font-size: 20px; + font-weight: 500; + margin-bottom: 8px; + color: var(--mdc-dialog-title-text-color, inherit); +} + +.dialog-content { + font-family: Roboto, sans-serif; + font-size: 14px; + color: var(--mdc-dialog-supporting-text-color, inherit); + line-height: 1.5; + + p { + margin-bottom: 12px; + color: var(--mdc-dialog-supporting-text-color, inherit); + } + + .info-section { + background-color: var(--builder-form-field-background-color, rgba(138, 180, 248, 0.08)); + border: 1px solid var(--builder-border-color, rgba(138, 180, 248, 0.2)); + border-radius: 8px; + padding: 10px 14px; + margin-bottom: 10px; + color: var(--mdc-dialog-supporting-text-color, inherit); + + strong { + color: var(--builder-text-link-color, #8ab4f8); + font-weight: 500; + } + } +} + +.dialog-actions { + padding: 16px 24px; + gap: 8px; + + button { + font-family: 'Google Sans', sans-serif; + font-weight: 500; + } + + button[color="primary"] { + background-color: var(--builder-text-link-color, #1a73e8) !important; + color: var(--mat-sys-background, #ffffff) !important; + + &:hover { + opacity: 0.9; + } + } +} \ No newline at end of file diff --git a/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.spec.ts b/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.spec.ts new file mode 100644 index 00000000..ba2b7c24 --- /dev/null +++ b/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.spec.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatDialogRef} from '@angular/material/dialog'; +import {provideNoopAnimations} from '@angular/platform-browser/animations'; +// 1p-ONLY-IMPORTS: import {beforeEach, describe, expect, it} + +import {TelemetryService} from '../../core/services/telemetry.service'; +import {initTestBed} from '../../testing/utils'; + +import {TelemetryConsentDialogComponent} from './telemetry-consent-dialog.component'; + +describe('TelemetryConsentDialogComponent', () => { + let component: TelemetryConsentDialogComponent; + let fixture: ComponentFixture; + let mockTelemetryService: jasmine.SpyObj; + let mockDialogRef: + jasmine.SpyObj>; + + initTestBed(); + + beforeEach(async () => { + mockTelemetryService = jasmine.createSpyObj( + 'TelemetryService', ['setTelemetry']); + mockDialogRef = + jasmine.createSpyObj>( + 'MatDialogRef', ['close']); + + await TestBed + .configureTestingModule({ + imports: [TelemetryConsentDialogComponent], + providers: [ + {provide: TelemetryService, useValue: mockTelemetryService}, + {provide: MatDialogRef, useValue: mockDialogRef}, + provideNoopAnimations(), + ], + }) + .compileComponents(); + + fixture = TestBed.createComponent(TelemetryConsentDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it( + 'should set telemetry to true and close when enable clicked', + () => { + component.onEnable(); + expect(mockTelemetryService.setTelemetry).toHaveBeenCalledWith(true); + expect(mockDialogRef.close).toHaveBeenCalledWith(true); + }); + + it( + 'should set telemetry to false and close when no thanks clicked', + () => { + component.onNoThanks(); + expect(mockTelemetryService.setTelemetry).toHaveBeenCalledWith(false); + expect(mockDialogRef.close).toHaveBeenCalledWith(false); + }); + + it( + 'should close dialog without setting preference when dismissed', () => { + component.onDismiss(); + expect(mockTelemetryService.setTelemetry).not.toHaveBeenCalled(); + expect(mockDialogRef.close).toHaveBeenCalledWith(); + }); +}); diff --git a/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.ts b/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.ts new file mode 100644 index 00000000..c42447dd --- /dev/null +++ b/src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatDialogModule, MatDialogRef} from '@angular/material/dialog'; +import {MatIconModule} from '@angular/material/icon'; + +import {TelemetryService} from '../../core/services/telemetry.service'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'app-telemetry-consent-dialog', + templateUrl: './telemetry-consent-dialog.component.html', + styleUrls: ['./telemetry-consent-dialog.component.scss'], + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + MatIconModule, + ], +}) +export class TelemetryConsentDialogComponent { + private readonly telemetryService = inject(TelemetryService); + private readonly dialogRef = + inject(MatDialogRef); + + onEnable(): void { + this.telemetryService.setTelemetry(true); + this.dialogRef.close(true); + } + + onNoThanks(): void { + this.telemetryService.setTelemetry(false); + this.dialogRef.close(false); + } + + onDismiss(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/core/models/RuntimeConfig.ts b/src/app/core/models/RuntimeConfig.ts index 6fd989a1..29d99541 100644 --- a/src/app/core/models/RuntimeConfig.ts +++ b/src/app/core/models/RuntimeConfig.ts @@ -22,6 +22,7 @@ export declare interface RuntimeConfig { backendUrl: string; logo?: LogoConfig; + telemetry?: boolean; } /** diff --git a/src/app/core/services/analytics.service.spec.ts b/src/app/core/services/analytics.service.spec.ts new file mode 100644 index 00000000..47a7926d --- /dev/null +++ b/src/app/core/services/analytics.service.spec.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { AnalyticsService, DEFAULT_GA_MEASUREMENT_ID } from './analytics.service'; +import { TelemetryService } from './telemetry.service'; + +describe('AnalyticsService', () => { + let service: AnalyticsService; + let mockTelemetryService: { + telemetryEnabled: ReturnType>; + }; + + beforeEach(() => { + mockTelemetryService = { + telemetryEnabled: signal(false), + }; + + TestBed.configureTestingModule({ + providers: [ + AnalyticsService, + { provide: TelemetryService, useValue: mockTelemetryService }, + ], + }); + + // Mock window.gtag + window.gtag = jasmine.createSpy('gtag'); + + service = TestBed.inject(AnalyticsService); + (service as any).measurementId = 'G-TEST123456'; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should NOT send events when telemetry consent is false', () => { + mockTelemetryService.telemetryEnabled.set(false); + service.sendEvent('test_event'); + expect(window.gtag).not.toHaveBeenCalled(); + }); + + it('should NOT set user properties when telemetry consent is false', () => { + mockTelemetryService.telemetryEnabled.set(false); + service.setUserProperties({ adk_version: '1.0.0' }); + expect(window.gtag).not.toHaveBeenCalled(); + }); + + it('should send events when telemetry consent is true', () => { + mockTelemetryService.telemetryEnabled.set(true); + service.sendEvent('chat_session_create'); + expect(window.gtag).toHaveBeenCalledWith('event', 'chat_session_create'); + }); + + it('should set user properties when telemetry consent is true', () => { + mockTelemetryService.telemetryEnabled.set(true); + service.setUserProperties({ adk_version: '1.0.0', adk_language: 'Python' }); + expect(window.gtag).toHaveBeenCalledWith('set', 'user_properties', { + adk_version: '1.0.0', + adk_language: 'Python', + }); + }); + + it('should disable analytics when telemetry consent becomes false', () => { + mockTelemetryService.telemetryEnabled.set(false); + TestBed.flushEffects(); + expect(window['ga-disable-G-TEST123456']).toBeTrue(); + }); +}); diff --git a/src/app/core/services/analytics.service.ts b/src/app/core/services/analytics.service.ts new file mode 100644 index 00000000..2cbb9743 --- /dev/null +++ b/src/app/core/services/analytics.service.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { inject, Injectable, effect } from '@angular/core'; +import { TelemetryService } from './telemetry.service'; + +declare global { + interface Window { + gtag?: (...args: any[]) => void; + dataLayer?: any[]; + [key: string]: any; + } +} + +// Production GA4 Measurement ID +export const DEFAULT_GA_MEASUREMENT_ID = 'G-19SDLPJMZN'; + +@Injectable({ + providedIn: 'root', +}) +export class AnalyticsService { + private readonly telemetryService = inject(TelemetryService); + private isGaInitialized = false; + private readonly measurementId = DEFAULT_GA_MEASUREMENT_ID; + + constructor() { + effect(() => { + const enabled = this.telemetryService.telemetryEnabled(); + if (enabled) { + this.enableAnalytics(); + } else { + this.disableAnalytics(); + } + }); + } + + /** + * Initializes GA4 gtag script if telemetry consent is granted. + */ + private enableAnalytics(): void { + if (typeof window === 'undefined' || !this.measurementId) return; + + // Enable GA4 tracking + (window as any)[`ga-disable-${this.measurementId}`] = false; + + if (!this.isGaInitialized) { + window.dataLayer = window.dataLayer || []; + window.gtag = window.gtag || function () { + window.dataLayer?.push(arguments); + }; + + // Load GA4 script + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${this.measurementId}`; + document.head.appendChild(script); + + window.gtag('js', new Date()); + window.gtag('config', this.measurementId); + this.isGaInitialized = true; + } + } + + /** + * Disables GA4 analytics tracking. + */ + private disableAnalytics(): void { + if (typeof window !== 'undefined' && this.measurementId) { + (window as any)[`ga-disable-${this.measurementId}`] = true; + } + } + + /** + * Sets GA4 User Properties (e.g. adk_version, adk_language). + */ + setUserProperties(properties: Record): void { + if (!this.telemetryService.telemetryEnabled() || !this.measurementId) return; + if (typeof window !== 'undefined' && window.gtag) { + window.gtag('set', 'user_properties', properties); + } + } + + /** + * Sends a standalone custom event without extra parameters. + */ + sendEvent(eventName: string): void { + if (!this.telemetryService.telemetryEnabled() || !this.measurementId) return; + if (typeof window !== 'undefined' && window.gtag) { + window.gtag('event', eventName); + } + } +} diff --git a/src/app/core/services/telemetry.service.spec.ts b/src/app/core/services/telemetry.service.spec.ts new file mode 100644 index 00000000..ad57b631 --- /dev/null +++ b/src/app/core/services/telemetry.service.spec.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {HttpClientTestingModule, HttpTestingController} from '@angular/common/http/testing'; +import {TestBed} from '@angular/core/testing'; +// 1p-ONLY-IMPORTS: import {beforeEach, describe, expect, it} + +import {RuntimeConfigUtil} from '../../../utils/runtime-config-util'; +import {URLUtil} from '../../../utils/url-util'; +import {initTestBed} from '../../testing/utils'; + +import {TelemetryService} from './telemetry.service'; + +const API_SERVER_BASE_URL = 'http://test.com'; + +describe('TelemetryService', () => { + let service: TelemetryService; + let httpTestingController: HttpTestingController; + + beforeEach(() => { + spyOn(URLUtil, 'getApiServerBaseUrl').and.returnValue(API_SERVER_BASE_URL); + spyOn(RuntimeConfigUtil, 'getRuntimeConfig').and.returnValue({ + backendUrl: 'http://test.com', + telemetry: true, + }); + initTestBed(); + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [TelemetryService], + }); + service = TestBed.inject(TelemetryService); + httpTestingController = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTestingController.verify(); + }); + + it('should load initial status from runtimeConfig', () => { + expect(service.telemetryStatus()).toBe(true); + expect(service.telemetryEnabled()).toBe(true); + }); + + it('should fetch data from API', async () => { + const promise = service.fetchTelemetryStatus(); + + const req = httpTestingController.expectOne( + `${API_SERVER_BASE_URL}/config/telemetry`); + expect(req.request.method).toBe('GET'); + req.flush({telemetry: false}); + + const status = await promise; + expect(status).toBe(false); + expect(service.telemetryStatus()).toBe(false); + expect(service.telemetryEnabled()).toBe(false); + }); + + it('should save data to API', async () => { + const promise = service.setTelemetry(true); + + const req = httpTestingController.expectOne( + `${API_SERVER_BASE_URL}/config/telemetry`); + expect(req.request.method).toBe('POST'); + expect(req.request.headers.get('X-ADK-Telemetry-Request')).toBe('true'); + expect(req.request.body).toEqual({telemetry: true}); + req.flush({telemetry: true}); + + await promise; + expect(service.telemetryStatus()).toBe(true); + }); + + it('should rollback telemetry status on API failure', async () => { + service.telemetryStatus.set(false); + + const promise = service.setTelemetry(true); + expect(service.telemetryStatus()).toBe(true); + + const req = httpTestingController.expectOne( + `${API_SERVER_BASE_URL}/config/telemetry`); + expect(req.request.method).toBe('POST'); + req.error(new ProgressEvent('Network error')); + + await promise; + expect(service.telemetryStatus()).toBe(false); + }); +}); diff --git a/src/app/core/services/telemetry.service.ts b/src/app/core/services/telemetry.service.ts new file mode 100644 index 00000000..0896a89d --- /dev/null +++ b/src/app/core/services/telemetry.service.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {HttpClient} from '@angular/common/http'; +import {computed, inject, Injectable, signal} from '@angular/core'; +import {firstValueFrom} from 'rxjs'; + +import {RuntimeConfigUtil} from '../../../utils/runtime-config-util'; +import {URLUtil} from '../../../utils/url-util'; + +export interface TelemetryConfigResponse { + telemetry: boolean|null; +} + +@Injectable({ + providedIn: 'root', +}) +export class TelemetryService { + private readonly http = inject(HttpClient); + + // State signals + // undefined: initializing, null: unset/no decision, true: opted-in, false: + // opted-out + readonly telemetryStatus = signal(undefined); + readonly telemetryEnabled = computed(() => this.telemetryStatus() === true); + + constructor() { + this.init(); + } + + private init(): void { + const runtimeConfig = RuntimeConfigUtil.getRuntimeConfig(); + if (runtimeConfig && runtimeConfig.telemetry !== undefined) { + this.telemetryStatus.set(runtimeConfig.telemetry); + } + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + this.fetchTelemetryStatus(); + } + }); + } + } + + async fetchTelemetryStatus(): Promise { + const baseUrl = URLUtil.getApiServerBaseUrl(); + try { + const res = await firstValueFrom(this.http.get( + `${baseUrl}/config/telemetry`)); + if (res && res.telemetry !== undefined) { + this.telemetryStatus.set(res.telemetry); + return res.telemetry; + } + } catch (e) { + console.error('Failed to fetch telemetry status:', e); + } + return this.telemetryStatus() ?? null; + } + + async setTelemetry(enabled: boolean): Promise { + const previousStatus = this.telemetryStatus(); + this.telemetryStatus.set(enabled); // Optimistic UI update + const baseUrl = URLUtil.getApiServerBaseUrl(); + try { + await firstValueFrom(this.http.post( + `${baseUrl}/config/telemetry`, + {telemetry: enabled}, + { + headers: {'X-ADK-Telemetry-Request': 'true'}, + }, + )); + } catch (e) { + console.error('Failed to save telemetry status:', e); + this.telemetryStatus.set(previousStatus); // Rollback on failure + } + } +} diff --git a/src/styles.scss b/src/styles.scss index e06e16b6..00458290 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -185,3 +185,9 @@ markdown code { border-radius: 0 !important; } } + +.telemetry-consent-dialog-panel { + .mdc-dialog__surface { + background-color: var(--mat-sys-surface-container, #1e1e1e) !important; + } +}