From 6de057acee216fbc0238d5eaf6ba9000cbaa4585 Mon Sep 17 00:00:00 2001 From: Lucas Kang Date: Fri, 17 Jul 2026 20:52:58 +0000 Subject: [PATCH 1/5] feat(web): Add telemetry consent modal and settings toggle Add TelemetryConsentDialogComponent and integrate it into ChatComponent initialization to prompt the user for consent when unset. Add a slide toggle for 'Usage Metrics' to the user settings menu, and implement optimistic updates with rollback capability on network/API failure. --- src/app/components/chat/chat.component.html | 13 +++ src/app/components/chat/chat.component.scss | 21 ++++ .../components/chat/chat.component.spec.ts | 63 +++++++++++ src/app/components/chat/chat.component.ts | 21 ++++ .../telemetry-consent-dialog.component.html | 20 ++++ .../telemetry-consent-dialog.component.scss | 52 +++++++++ ...telemetry-consent-dialog.component.spec.ts | 86 +++++++++++++++ .../telemetry-consent-dialog.component.ts | 55 ++++++++++ src/app/core/models/RuntimeConfig.ts | 1 + .../core/services/telemetry.service.spec.ts | 100 ++++++++++++++++++ src/app/core/services/telemetry.service.ts | 84 +++++++++++++++ src/styles.scss | 6 ++ 12 files changed, 522 insertions(+) create mode 100644 src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.html create mode 100644 src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.scss create mode 100644 src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.spec.ts create mode 100644 src/app/components/telemetry-consent-dialog/telemetry-consent-dialog.component.ts create mode 100644 src/app/core/services/telemetry.service.spec.ts create mode 100644 src/app/core/services/telemetry.service.ts 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..def4c211 100644 --- a/src/app/components/chat/chat.component.ts +++ b/src/app/components/chat/chat.component.ts @@ -84,6 +84,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 +184,8 @@ const BIDI_STREAMING_IN_PROGRESS_WARNING = SessionTabComponent, InlineEditComponent, FormatMetricNamePipe, + MatSlideToggleModule, + TelemetryConsentDialogComponent, ], }) export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { @@ -214,6 +219,7 @@ 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 logoComponent: Type | null = inject(LOGO_COMPONENT, { optional: true, }); @@ -860,6 +866,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { }); } }); + this.checkTelemetryConsent(); } get sessionTab() { @@ -870,6 +877,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(); 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/telemetry.service.spec.ts b/src/app/core/services/telemetry.service.spec.ts new file mode 100644 index 00000000..1df7d7a4 --- /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}/api/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}/api/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}/api/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..7c590ff9 --- /dev/null +++ b/src/app/core/services/telemetry.service.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 {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); + } + } + + async fetchTelemetryStatus(): Promise { + const baseUrl = URLUtil.getApiServerBaseUrl(); + try { + const res = await firstValueFrom(this.http.get( + `${baseUrl}/api/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}/api/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; + } +} From 246f961a659e56581fbe05bd8abff559aaaebf38 Mon Sep 17 00:00:00 2001 From: Lucas Kang Date: Fri, 17 Jul 2026 22:19:02 +0000 Subject: [PATCH 2/5] feat(web): Auto-resync telemetry status when tab gains visibility --- src/app/core/services/telemetry.service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/core/services/telemetry.service.ts b/src/app/core/services/telemetry.service.ts index 7c590ff9..30d77b87 100644 --- a/src/app/core/services/telemetry.service.ts +++ b/src/app/core/services/telemetry.service.ts @@ -47,6 +47,14 @@ export class TelemetryService { 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 { From ba36143f4116a9e11537dfb406eb441364bd66e1 Mon Sep 17 00:00:00 2001 From: Lucas Kang Date: Sat, 18 Jul 2026 21:25:06 +0000 Subject: [PATCH 3/5] feat(web): Instrument GA4 events and user properties for telemetry tracking - Create AnalyticsService to manage GA4 initialization, User Properties, and event dispatches gated by user consent. - Set adk_version and adk_language User Properties via gtag when /version API resolves. - Instrument standalone telemetry events for feature usage: chat_session_create, builder_enter_click, builder_agent_save_click, graph_view_click, trace_view_toggle, event_view_toggle, and eval_create_click. - Add comprehensive AnalyticsService unit tests. --- .../builder-tabs/builder-tabs.component.ts | 3 + src/app/components/chat/chat.component.ts | 9 ++ .../add-eval-session-dialog.component.ts | 3 + .../side-panel/side-panel.component.ts | 8 ++ .../core/services/analytics.service.spec.ts | 84 ++++++++++++++ src/app/core/services/analytics.service.ts | 106 ++++++++++++++++++ 6 files changed, 213 insertions(+) create mode 100644 src/app/core/services/analytics.service.spec.ts create mode 100644 src/app/core/services/analytics.service.ts 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.ts b/src/app/components/chat/chat.component.ts index def4c211..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'; @@ -220,6 +221,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { 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, }); @@ -767,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([ @@ -1105,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) { @@ -3106,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) { @@ -3167,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/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..d97092ad --- /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; + } +} + +// TODO: Replace with production GA4 Measurement ID +export const DEFAULT_GA_MEASUREMENT_ID = ''; + +@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); + } + } +} From 6348eca2dcd76fabde69631db15cc50343d78763 Mon Sep 17 00:00:00 2001 From: Lucas Kang Date: Wed, 22 Jul 2026 17:52:23 +0000 Subject: [PATCH 4/5] fix(web): Align telemetry endpoint path with backend dev server Update TelemetryService and unit tests to request /config/telemetry instead of /api/config/telemetry to match the FastAPI route registered in dev_server.py. --- src/app/core/services/telemetry.service.spec.ts | 6 +++--- src/app/core/services/telemetry.service.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/app/core/services/telemetry.service.spec.ts b/src/app/core/services/telemetry.service.spec.ts index 1df7d7a4..ad57b631 100644 --- a/src/app/core/services/telemetry.service.spec.ts +++ b/src/app/core/services/telemetry.service.spec.ts @@ -59,7 +59,7 @@ describe('TelemetryService', () => { const promise = service.fetchTelemetryStatus(); const req = httpTestingController.expectOne( - `${API_SERVER_BASE_URL}/api/config/telemetry`); + `${API_SERVER_BASE_URL}/config/telemetry`); expect(req.request.method).toBe('GET'); req.flush({telemetry: false}); @@ -73,7 +73,7 @@ describe('TelemetryService', () => { const promise = service.setTelemetry(true); const req = httpTestingController.expectOne( - `${API_SERVER_BASE_URL}/api/config/telemetry`); + `${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}); @@ -90,7 +90,7 @@ describe('TelemetryService', () => { expect(service.telemetryStatus()).toBe(true); const req = httpTestingController.expectOne( - `${API_SERVER_BASE_URL}/api/config/telemetry`); + `${API_SERVER_BASE_URL}/config/telemetry`); expect(req.request.method).toBe('POST'); req.error(new ProgressEvent('Network error')); diff --git a/src/app/core/services/telemetry.service.ts b/src/app/core/services/telemetry.service.ts index 30d77b87..0896a89d 100644 --- a/src/app/core/services/telemetry.service.ts +++ b/src/app/core/services/telemetry.service.ts @@ -61,7 +61,7 @@ export class TelemetryService { const baseUrl = URLUtil.getApiServerBaseUrl(); try { const res = await firstValueFrom(this.http.get( - `${baseUrl}/api/config/telemetry`)); + `${baseUrl}/config/telemetry`)); if (res && res.telemetry !== undefined) { this.telemetryStatus.set(res.telemetry); return res.telemetry; @@ -78,7 +78,7 @@ export class TelemetryService { const baseUrl = URLUtil.getApiServerBaseUrl(); try { await firstValueFrom(this.http.post( - `${baseUrl}/api/config/telemetry`, + `${baseUrl}/config/telemetry`, {telemetry: enabled}, { headers: {'X-ADK-Telemetry-Request': 'true'}, From fe7b5d3582aa08149904faeb9413336e4d88c14d Mon Sep 17 00:00:00 2001 From: lkang172 Date: Mon, 27 Jul 2026 18:10:50 +0000 Subject: [PATCH 5/5] chore(web): Set production GA4 measurement ID for telemetry tracking --- src/app/core/services/analytics.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/core/services/analytics.service.ts b/src/app/core/services/analytics.service.ts index d97092ad..2cbb9743 100644 --- a/src/app/core/services/analytics.service.ts +++ b/src/app/core/services/analytics.service.ts @@ -26,8 +26,8 @@ declare global { } } -// TODO: Replace with production GA4 Measurement ID -export const DEFAULT_GA_MEASUREMENT_ID = ''; +// Production GA4 Measurement ID +export const DEFAULT_GA_MEASUREMENT_ID = 'G-19SDLPJMZN'; @Injectable({ providedIn: 'root',