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..30d77b87 --- /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}/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; + } +}