diff --git a/docs/guides/telemetry.md b/docs/guides/telemetry.md new file mode 100644 index 0000000000..6156f050ee --- /dev/null +++ b/docs/guides/telemetry.md @@ -0,0 +1,162 @@ +[comment]: # (@label Telemetria) +[comment]: # (@link guides/telemetry) + +## Telemetria do PO UI + +O PO UI oferece um serviço de telemetria **opt-in** que permite coletar dados anônimos sobre o uso dos componentes da biblioteca. Esses dados ajudam a equipe de desenvolvimento a entender quais componentes são mais utilizados, priorizar melhorias e corrigir problemas. + +### O que é coletado + +Os eventos de telemetria contêm apenas as seguintes informações: + +| Campo | Descrição | +|---|---| +| `componentName` | Nome do componente utilizado (ex: `po-button`, `po-table`) | +| `libraryVersion` | Versão da biblioteca `@po-ui/ng-components` | +| `angularVersion` | Versão do Angular utilizada na aplicação | +| `timestamp` | Data e hora do evento em formato ISO 8601 | +| `sessionId` | Identificador aleatório da sessão do navegador | + +> **Nenhum dado pessoal, de negócio ou sensível é coletado.** Não são rastreados dados de formulário, interações do usuário ou informações de identificação pessoal. + +### Como habilitar + +A telemetria está **desabilitada por padrão**. Para habilitá-la, utilize o `PoTelemetryModule.forRoot()` na configuração do seu módulo ou aplicação: + +**Abordagem com NgModule:** + +```typescript +import { PoTelemetryModule } from '@po-ui/ng-components'; + +@NgModule({ + imports: [ + PoModule, + PoTelemetryModule.forRoot({ + enabled: true, + endpointUrl: 'https://my-telemetry-api.example.com/events', + showConsentDialog: true + }) + ] +}) +export class AppModule {} +``` + +**Abordagem Standalone:** + +```typescript +import { importProvidersFrom } from '@angular/core'; +import { PoTelemetryModule } from '@po-ui/ng-components'; + +bootstrapApplication(AppComponent, { + providers: [ + importProvidersFrom(PoTelemetryModule.forRoot({ + enabled: true, + endpointUrl: 'https://my-telemetry-api.example.com/events', + showConsentDialog: true + })) + ] +}); +``` + +### Configurações disponíveis + +| Propriedade | Tipo | Padrão | Descrição | +|---|---|---|---| +| `enabled` | `boolean` | `false` | Habilita ou desabilita a coleta de telemetria | +| `endpointUrl` | `string` | — | URL do endpoint que receberá os eventos | +| `consentStorageKey` | `string` | `po-telemetry-consent` | Chave no `localStorage` para armazenar o consentimento | +| `batchIntervalMs` | `number` | `30000` | Intervalo em milissegundos para envio em batch | +| `showConsentDialog` | `boolean` | `true` | Exibe diálogo de consentimento na primeira vez | +| `consentDialogLiterals` | `object` | — | Literais customizadas para o diálogo de consentimento | + +### Como o consentimento funciona + +O serviço de telemetria implementa um mecanismo de consentimento em duas camadas: + +1. **Configuração do desenvolvedor**: A telemetria só é ativada quando `enabled: true` é definido na configuração. +2. **Consentimento do usuário final**: Mesmo com a telemetria habilitada, os dados só são coletados após o consentimento explícito do usuário. + +O fluxo de consentimento funciona da seguinte forma: + +- Ao inicializar, o serviço verifica o `localStorage` pela chave configurada (`po-telemetry-consent` por padrão). +- Se não houver valor armazenado e `showConsentDialog` estiver habilitado, um diálogo de confirmação é exibido ao usuário utilizando o `PoDialogService`. +- Se o usuário **aceitar**, o valor `granted` é salvo no `localStorage` e a coleta de dados é iniciada. +- Se o usuário **recusar**, o valor `denied` é salvo e nenhum dado é coletado. + +### Controle programático + +O `PoTelemetryService` expõe métodos para controle programático do consentimento: + +```typescript +import { PoTelemetryService } from '@po-ui/ng-components'; + +@Component({ ... }) +export class SettingsComponent { + constructor(private telemetryService: PoTelemetryService) {} + + enableTelemetry() { + this.telemetryService.grantConsent(); + } + + disableTelemetry() { + this.telemetryService.revokeConsent(); + } +} +``` + +### Como desabilitar + +Para desabilitar completamente a telemetria, basta: + +- **Não importar** o `PoTelemetryModule.forRoot()` na aplicação, ou +- Definir `enabled: false` na configuração + +Para revogar o consentimento de um usuário que já havia consentido: + +```typescript +this.telemetryService.revokeConsent(); +``` + +### Customização do diálogo de consentimento + +As literais do diálogo de consentimento podem ser customizadas: + +```typescript +PoTelemetryModule.forRoot({ + enabled: true, + endpointUrl: 'https://my-telemetry-api.example.com/events', + consentDialogLiterals: { + title: 'Coleta de dados de uso', + message: 'Gostaríamos de coletar dados anônimos sobre o uso dos componentes para melhorar a experiência. Nenhum dado pessoal é coletado. Deseja permitir?', + confirm: 'Sim, permitir', + cancel: 'Não, obrigado' + } +}) +``` + +### Política de privacidade recomendada + +Se a sua aplicação utiliza a telemetria do PO UI, é recomendado incluir na sua política de privacidade uma seção informando: + +- Que dados anônimos de uso de componentes de interface são coletados +- Que nenhum dado pessoal ou de negócio é transmitido +- Que o usuário pode revogar o consentimento a qualquer momento +- O endpoint para o qual os dados são enviados + +### Formato do payload + +Os eventos são enviados em batch via `POST` para o endpoint configurado. O corpo da requisição é um array de objetos: + +```json +[ + { + "componentName": "po-table", + "libraryVersion": "21.4.0", + "angularVersion": "21.0.3", + "timestamp": "2026-03-05T12:00:00.000Z", + "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + } +] +``` + +Em caso de falha no envio, os eventos são mantidos em buffer e reenviados na próxima tentativa. diff --git a/projects/portal/src/app/guide/guide-routing.module.ts b/projects/portal/src/app/guide/guide-routing.module.ts index b84ecfb033..ffe9c60243 100644 --- a/projects/portal/src/app/guide/guide-routing.module.ts +++ b/projects/portal/src/app/guide/guide-routing.module.ts @@ -16,6 +16,7 @@ import { GuideReleasesComponent } from './guides/guide-releases/guide-releases.c import { GuideSchematicsComponent } from './guides/guide-schematics/guide-schematics.component'; import { GuideSyncFundamentalsComponent } from './guides/guide-sync-fundamentals/guide-sync-fundamentals.component'; import { GuideSyncGetStartedComponent } from './guides/guide-sync-get-started/guide-sync-get-started.component'; +import { GuideTelemetryComponent } from './guides/guide-telemetry/guide-telemetry.component'; import { GuideThemeServiceComponent } from './guides/guide-theme-service/guide-theme-service.component'; import { GuideCreateThemeCustomizationComponent } from './guides/guide-create-theme-customization/guide-create-theme-customization.component'; import { GuideGridSystemComponent } from './guides/guide-grid-system/guide-grid-system.component'; @@ -43,6 +44,7 @@ export const guidesRoutes: Routes = [ { path: 'schematics', component: GuideSchematicsComponent }, { path: 'sync-fundamentals', component: GuideSyncFundamentalsComponent }, { path: 'sync-get-started', component: GuideSyncGetStartedComponent }, + { path: 'telemetry', component: GuideTelemetryComponent }, { path: 'theme-service', component: GuideThemeServiceComponent }, { path: 'create-theme-customization', component: GuideCreateThemeCustomizationComponent }, { path: 'grid-system', component: GuideGridSystemComponent }, diff --git a/projects/portal/src/app/guide/guide.module.ts b/projects/portal/src/app/guide/guide.module.ts index d9c370bf26..a8559968d8 100644 --- a/projects/portal/src/app/guide/guide.module.ts +++ b/projects/portal/src/app/guide/guide.module.ts @@ -19,6 +19,7 @@ import { GuideReleasesComponent } from './guides/guide-releases/guide-releases.c import { GuideSchematicsComponent } from './guides/guide-schematics/guide-schematics.component'; import { GuideSyncFundamentalsComponent } from './guides/guide-sync-fundamentals/guide-sync-fundamentals.component'; import { GuideSyncGetStartedComponent } from './guides/guide-sync-get-started/guide-sync-get-started.component'; +import { GuideTelemetryComponent } from './guides/guide-telemetry/guide-telemetry.component'; import { GuideThemeServiceComponent } from './guides/guide-theme-service/guide-theme-service.component'; import { GuideCreateThemeCustomizationComponent } from './guides/guide-create-theme-customization/guide-create-theme-customization.component'; import { GuideGridSystemComponent } from './guides/guide-grid-system/guide-grid-system.component'; @@ -44,6 +45,7 @@ import { GuideTypographyComponent } from './guides/guide-typography/guide-typogr GuideSchematicsComponent, GuideSyncFundamentalsComponent, GuideSyncGetStartedComponent, + GuideTelemetryComponent, GuideThemeServiceComponent, GuideCreateThemeCustomizationComponent, GuideGridSystemComponent, diff --git a/projects/portal/src/app/guide/menu-guides.service.ts b/projects/portal/src/app/guide/menu-guides.service.ts index a21412eff5..0b432c9616 100644 --- a/projects/portal/src/app/guide/menu-guides.service.ts +++ b/projects/portal/src/app/guide/menu-guides.service.ts @@ -24,6 +24,7 @@ export class MenuGuidesService { { label: 'Schematics', link: 'guides/schematics' }, { label: 'Fundamentos do PO Sync', link: 'guides/sync-fundamentals' }, { label: 'Começando com o PO Sync', link: 'guides/sync-get-started' }, + { label: 'Telemetria', link: 'guides/telemetry' }, { label: 'Customização de Temas usando o serviço PO-UI', link: 'guides/theme-service' }, { label: 'Criando um tema para o PO UI', link: 'guides/create-theme-customization' }, { label: 'Grid System', link: 'guides/grid-system' }, diff --git a/projects/ui/src/lib/services/index.ts b/projects/ui/src/lib/services/index.ts index 5585099497..7f9419297d 100644 --- a/projects/ui/src/lib/services/index.ts +++ b/projects/ui/src/lib/services/index.ts @@ -10,4 +10,5 @@ export * from './po-language/index'; export * from './po-i18n/index'; export * from './po-media-query/index'; export * from './po-notification/index'; +export * from './po-telemetry/index'; export * from './po-theme/index'; diff --git a/projects/ui/src/lib/services/po-telemetry/index.ts b/projects/ui/src/lib/services/po-telemetry/index.ts new file mode 100644 index 0000000000..8703cea3a8 --- /dev/null +++ b/projects/ui/src/lib/services/po-telemetry/index.ts @@ -0,0 +1,4 @@ +export * from './po-telemetry-config.interface'; +export * from './po-telemetry.injection-token'; +export * from './po-telemetry.module'; +export * from './po-telemetry.service'; diff --git a/projects/ui/src/lib/services/po-telemetry/po-telemetry-config.interface.ts b/projects/ui/src/lib/services/po-telemetry/po-telemetry-config.interface.ts new file mode 100644 index 0000000000..bafcb46e1c --- /dev/null +++ b/projects/ui/src/lib/services/po-telemetry/po-telemetry-config.interface.ts @@ -0,0 +1,43 @@ +/** + * @usedBy PoTelemetryModule, PoTelemetryService + * + * @description + * + * Interface de configuração do serviço de telemetria. + */ +export interface PoTelemetryConfig { + /** Se a telemetria está habilitada (default: false — opt-in). */ + enabled: boolean; + + /** URL do endpoint que receberá os eventos. */ + endpointUrl: string; + + /** + * Chave no `localStorage` para armazenar consentimento do usuário. + * + * @default `po-telemetry-consent` + */ + consentStorageKey?: string; + + /** + * Intervalo em milissegundos para envio em batch. + * + * @default `30000` + */ + batchIntervalMs?: number; + + /** + * Exibir diálogo de consentimento ao usuário na primeira vez. + * + * @default `true` + */ + showConsentDialog?: boolean; + + /** Literais customizadas para o diálogo de consentimento. */ + consentDialogLiterals?: { + title?: string; + message?: string; + confirm?: string; + cancel?: string; + }; +} diff --git a/projects/ui/src/lib/services/po-telemetry/po-telemetry.injection-token.ts b/projects/ui/src/lib/services/po-telemetry/po-telemetry.injection-token.ts new file mode 100644 index 0000000000..670ae6e3e6 --- /dev/null +++ b/projects/ui/src/lib/services/po-telemetry/po-telemetry.injection-token.ts @@ -0,0 +1,5 @@ +import { InjectionToken } from '@angular/core'; + +import { PoTelemetryConfig } from './po-telemetry-config.interface'; + +export const PO_TELEMETRY_CONFIG = new InjectionToken('PO_TELEMETRY_CONFIG'); diff --git a/projects/ui/src/lib/services/po-telemetry/po-telemetry.module.ts b/projects/ui/src/lib/services/po-telemetry/po-telemetry.module.ts new file mode 100644 index 0000000000..53e26c7193 --- /dev/null +++ b/projects/ui/src/lib/services/po-telemetry/po-telemetry.module.ts @@ -0,0 +1,43 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; + +import { PO_TELEMETRY_CONFIG } from './po-telemetry.injection-token'; +import { PoTelemetryConfig } from './po-telemetry-config.interface'; +import { PoTelemetryService } from './po-telemetry.service'; + +/** + * @description + * + * Módulo do serviço de telemetria do PO UI. + * + * Para utilização do serviço de telemetria, deve-se importar este módulo e invocar o método `forRoot`, + * informando um objeto que implementa a interface `PoTelemetryConfig`. + * + * A telemetria é **opt-in** e requer consentimento do usuário. + * + * **Exemplo de configuração:** + * + * ``` + * import { PoTelemetryModule } from '@po-ui/ng-components'; + * + * @NgModule({ + * imports: [ + * PoModule, + * PoTelemetryModule.forRoot({ + * enabled: true, + * endpointUrl: 'https://my-telemetry-api.example.com/events', + * showConsentDialog: true + * }) + * ] + * }) + * export class AppModule {} + * ``` + */ +@NgModule({}) +export class PoTelemetryModule { + static forRoot(config: PoTelemetryConfig): ModuleWithProviders { + return { + ngModule: PoTelemetryModule, + providers: [{ provide: PO_TELEMETRY_CONFIG, useValue: config }, PoTelemetryService] + }; + } +} diff --git a/projects/ui/src/lib/services/po-telemetry/po-telemetry.service.spec.ts b/projects/ui/src/lib/services/po-telemetry/po-telemetry.service.spec.ts new file mode 100644 index 0000000000..f6836c10a2 --- /dev/null +++ b/projects/ui/src/lib/services/po-telemetry/po-telemetry.service.spec.ts @@ -0,0 +1,300 @@ +import { HttpClient } from '@angular/common/http'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; + +import { PoDialogService } from '../po-dialog/po-dialog.service'; + +import { PO_TELEMETRY_CONFIG } from './po-telemetry.injection-token'; +import { PoTelemetryConfig } from './po-telemetry-config.interface'; +import { PoTelemetryService } from './po-telemetry.service'; + +describe('PoTelemetryService', () => { + let service: PoTelemetryService; + let httpClientSpy: jasmine.SpyObj; + let dialogServiceSpy: jasmine.SpyObj; + + function createService(config: PoTelemetryConfig | null, consentValue: string | null = null): PoTelemetryService { + if (consentValue !== null) { + spyOn(localStorage, 'getItem').and.returnValue(consentValue); + } else { + spyOn(localStorage, 'getItem').and.returnValue(null); + } + spyOn(localStorage, 'setItem'); + + httpClientSpy = jasmine.createSpyObj('HttpClient', ['post']); + httpClientSpy.post.and.returnValue(of({})); + + dialogServiceSpy = jasmine.createSpyObj('PoDialogService', ['confirm']); + + const providers: Array = [ + PoTelemetryService, + { provide: HttpClient, useValue: httpClientSpy }, + { provide: PoDialogService, useValue: dialogServiceSpy } + ]; + + if (config) { + providers.push({ provide: PO_TELEMETRY_CONFIG, useValue: config }); + } + + TestBed.configureTestingModule({ providers }); + + return TestBed.inject(PoTelemetryService); + } + + afterEach(() => { + if (service) { + service.ngOnDestroy(); + } + TestBed.resetTestingModule(); + }); + + describe('quando enabled é false:', () => { + it('não deve coletar eventos', () => { + service = createService({ enabled: false, endpointUrl: 'https://api.example.com/events' }, 'granted'); + + service.trackComponentUsage('po-button'); + + service.flushEvents(); + expect(httpClientSpy.post).not.toHaveBeenCalled(); + }); + }); + + describe('quando consentimento é denied:', () => { + it('não deve coletar eventos', () => { + service = createService({ enabled: true, endpointUrl: 'https://api.example.com/events' }, 'denied'); + + service.trackComponentUsage('po-button'); + + service.flushEvents(); + expect(httpClientSpy.post).not.toHaveBeenCalled(); + }); + }); + + describe('quando enabled é true e consentimento é granted:', () => { + it('deve coletar e enviar eventos', () => { + service = createService({ enabled: true, endpointUrl: 'https://api.example.com/events' }, 'granted'); + + service.trackComponentUsage('po-button'); + service.trackComponentUsage('po-table'); + + service.flushEvents(); + + expect(httpClientSpy.post).toHaveBeenCalledTimes(1); + const [url, payload] = httpClientSpy.post.calls.mostRecent().args; + expect(url).toBe('https://api.example.com/events'); + expect((payload as Array).length).toBe(2); + }); + + it('deve incluir o formato correto no payload', () => { + service = createService({ enabled: true, endpointUrl: 'https://api.example.com/events' }, 'granted'); + + service.trackComponentUsage('po-input'); + + service.flushEvents(); + + const [, payload] = httpClientSpy.post.calls.mostRecent().args; + const events = payload as Array; + expect(events.length).toBe(1); + + const event = events[0]; + expect(event.componentName).toBe('po-input'); + expect(event.libraryVersion).toBeDefined(); + expect(event.angularVersion).toBeDefined(); + expect(event.timestamp).toBeDefined(); + expect(event.sessionId).toBeDefined(); + }); + }); + + describe('diálogo de consentimento:', () => { + it('deve exibir diálogo quando não há preferência salva e showConsentDialog é true', () => { + service = createService({ + enabled: true, + endpointUrl: 'https://api.example.com/events', + showConsentDialog: true + }); + + expect(dialogServiceSpy.confirm).toHaveBeenCalledTimes(1); + }); + + it('não deve exibir diálogo quando showConsentDialog é false', () => { + service = createService({ + enabled: true, + endpointUrl: 'https://api.example.com/events', + showConsentDialog: false + }); + + expect(dialogServiceSpy.confirm).not.toHaveBeenCalled(); + }); + + it('não deve exibir diálogo quando já há consentimento registrado', () => { + service = createService( + { + enabled: true, + endpointUrl: 'https://api.example.com/events', + showConsentDialog: true + }, + 'granted' + ); + + expect(dialogServiceSpy.confirm).not.toHaveBeenCalled(); + }); + + it('deve conceder consentimento ao confirmar o diálogo', () => { + service = createService({ + enabled: true, + endpointUrl: 'https://api.example.com/events', + showConsentDialog: true + }); + + const confirmCall = dialogServiceSpy.confirm.calls.mostRecent().args[0]; + confirmCall.confirm(); + + expect(localStorage.setItem).toHaveBeenCalledWith('po-telemetry-consent', 'granted'); + }); + + it('deve negar consentimento ao cancelar o diálogo', () => { + service = createService({ + enabled: true, + endpointUrl: 'https://api.example.com/events', + showConsentDialog: true + }); + + const confirmCall = dialogServiceSpy.confirm.calls.mostRecent().args[0]; + if (confirmCall.cancel) { + confirmCall.cancel(); + } + + expect(localStorage.setItem).toHaveBeenCalledWith('po-telemetry-consent', 'denied'); + }); + + it('deve usar literais customizadas no diálogo', () => { + service = createService({ + enabled: true, + endpointUrl: 'https://api.example.com/events', + showConsentDialog: true, + consentDialogLiterals: { + title: 'Custom Title', + message: 'Custom Message', + confirm: 'Accept', + cancel: 'Decline' + } + }); + + const confirmCall = dialogServiceSpy.confirm.calls.mostRecent().args[0]; + expect(confirmCall.title).toBe('Custom Title'); + expect(confirmCall.message).toBe('Custom Message'); + expect(confirmCall.literals?.confirm).toBe('Accept'); + expect(confirmCall.literals?.cancel).toBe('Decline'); + }); + }); + + describe('envio em batch:', () => { + it('deve enviar eventos acumulados no buffer ao chamar flushEvents', () => { + service = createService({ enabled: true, endpointUrl: 'https://api.example.com/events' }, 'granted'); + + service.trackComponentUsage('po-button'); + service.trackComponentUsage('po-table'); + service.trackComponentUsage('po-combo'); + + service.flushEvents(); + + expect(httpClientSpy.post).toHaveBeenCalledTimes(1); + const [, payload] = httpClientSpy.post.calls.mostRecent().args; + expect((payload as Array).length).toBe(3); + }); + + it('não deve enviar quando o buffer está vazio', () => { + service = createService({ enabled: true, endpointUrl: 'https://api.example.com/events' }, 'granted'); + + service.flushEvents(); + + expect(httpClientSpy.post).not.toHaveBeenCalled(); + }); + + it('deve limpar o buffer após envio bem-sucedido', () => { + service = createService({ enabled: true, endpointUrl: 'https://api.example.com/events' }, 'granted'); + + service.trackComponentUsage('po-button'); + service.flushEvents(); + + expect(httpClientSpy.post).toHaveBeenCalledTimes(1); + + service.flushEvents(); + expect(httpClientSpy.post).toHaveBeenCalledTimes(1); + }); + }); + + describe('retry em caso de falha HTTP:', () => { + it('deve manter eventos no buffer quando o envio falhar', () => { + service = createService({ enabled: true, endpointUrl: 'https://api.example.com/events' }, 'granted'); + + httpClientSpy.post.and.returnValue(throwError(() => new Error('Network error'))); + + service.trackComponentUsage('po-button'); + service.flushEvents(); + + httpClientSpy.post.and.returnValue(of({})); + + service.flushEvents(); + + expect(httpClientSpy.post).toHaveBeenCalledTimes(2); + const [, payload] = httpClientSpy.post.calls.mostRecent().args; + expect((payload as Array).length).toBe(1); + }); + }); + + describe('controle programático de consentimento:', () => { + it('deve permitir conceder consentimento via grantConsent()', () => { + service = createService( + { + enabled: true, + endpointUrl: 'https://api.example.com/events', + showConsentDialog: false + }, + null + ); + + service.grantConsent(); + service.trackComponentUsage('po-button'); + service.flushEvents(); + + expect(httpClientSpy.post).toHaveBeenCalledTimes(1); + expect(localStorage.setItem).toHaveBeenCalledWith('po-telemetry-consent', 'granted'); + }); + + it('deve permitir revogar consentimento via revokeConsent()', () => { + service = createService({ enabled: true, endpointUrl: 'https://api.example.com/events' }, 'granted'); + + service.revokeConsent(); + service.trackComponentUsage('po-button'); + service.flushEvents(); + + expect(httpClientSpy.post).not.toHaveBeenCalled(); + expect(localStorage.setItem).toHaveBeenCalledWith('po-telemetry-consent', 'denied'); + }); + }); + + describe('sem config fornecida:', () => { + it('deve funcionar sem config (telemetria desabilitada por padrão)', () => { + service = createService(null); + + service.trackComponentUsage('po-button'); + service.flushEvents(); + + expect(httpClientSpy.post).not.toHaveBeenCalled(); + }); + }); + + describe('consentStorageKey customizada:', () => { + it('deve usar a chave customizada no localStorage', () => { + service = createService({ + enabled: true, + endpointUrl: 'https://api.example.com/events', + consentStorageKey: 'my-app-consent', + showConsentDialog: false + }); + + expect(localStorage.getItem).toHaveBeenCalledWith('my-app-consent'); + }); + }); +}); diff --git a/projects/ui/src/lib/services/po-telemetry/po-telemetry.service.ts b/projects/ui/src/lib/services/po-telemetry/po-telemetry.service.ts new file mode 100644 index 0000000000..db5f1bcebe --- /dev/null +++ b/projects/ui/src/lib/services/po-telemetry/po-telemetry.service.ts @@ -0,0 +1,217 @@ +import { HttpClient } from '@angular/common/http'; +import { Inject, Injectable, NgZone, OnDestroy, Optional } from '@angular/core'; +import { VERSION } from '@angular/core'; + +import { PoDialogService } from '../po-dialog/po-dialog.service'; + +import { PO_UI_VERSION } from '../../utils/po-version'; +import { PO_TELEMETRY_CONFIG } from './po-telemetry.injection-token'; +import { PoTelemetryConfig } from './po-telemetry-config.interface'; + +interface PoTelemetryEvent { + componentName: string; + libraryVersion: string; + angularVersion: string; + timestamp: string; + sessionId: string; +} + +const DEFAULT_CONSENT_STORAGE_KEY = 'po-telemetry-consent'; +const DEFAULT_BATCH_INTERVAL_MS = 30000; + +/** + * @description + * + * Serviço responsável por coletar e enviar dados de telemetria sobre o uso dos componentes PO UI. + * + * A telemetria é **opt-in**, ou seja, está desabilitada por padrão e só é ativada quando + * explicitamente configurada pelo consumidor da biblioteca via `PoTelemetryModule.forRoot()`. + * + * Além da configuração programática, o serviço também respeita o consentimento do usuário final, + * armazenado no `localStorage`. Caso `showConsentDialog` esteja habilitado e não exista + * consentimento registrado, um diálogo será exibido ao usuário. + * + * Os eventos coletados são enviados em batch para o endpoint configurado. + */ +@Injectable() +export class PoTelemetryService implements OnDestroy { + private buffer: Array = []; + private consentStorageKey: string; + private batchIntervalMs: number; + private endpointUrl: string; + private enabled: boolean; + private showConsentDialog: boolean; + private sessionId: string; + private intervalId: any; + private userConsent: boolean | null = null; + private consentDialogLiterals: PoTelemetryConfig['consentDialogLiterals']; + + constructor( + @Optional() @Inject(PO_TELEMETRY_CONFIG) private config: PoTelemetryConfig, + @Optional() private httpClient: HttpClient, + @Optional() private poDialogService: PoDialogService, + private ngZone: NgZone + ) { + this.enabled = this.config?.enabled ?? false; + this.endpointUrl = this.config?.endpointUrl ?? ''; + this.consentStorageKey = this.config?.consentStorageKey ?? DEFAULT_CONSENT_STORAGE_KEY; + this.batchIntervalMs = this.config?.batchIntervalMs ?? DEFAULT_BATCH_INTERVAL_MS; + this.showConsentDialog = this.config?.showConsentDialog ?? true; + this.consentDialogLiterals = this.config?.consentDialogLiterals; + this.sessionId = this.generateSessionId(); + + this.initConsent(); + this.startBatchInterval(); + } + + /** + * Registra o uso de um componente para telemetria. + * + * @param componentName Nome do componente utilizado. + */ + trackComponentUsage(componentName: string): void { + if (!this.enabled || this.userConsent !== true) { + return; + } + + const event: PoTelemetryEvent = { + componentName, + libraryVersion: PO_UI_VERSION, + angularVersion: VERSION.full, + timestamp: new Date().toISOString(), + sessionId: this.sessionId + }; + + this.buffer.push(event); + } + + /** + * Concede consentimento programaticamente. + */ + grantConsent(): void { + this.userConsent = true; + this.setConsentInStorage('granted'); + } + + /** + * Revoga consentimento programaticamente. + */ + revokeConsent(): void { + this.userConsent = false; + this.setConsentInStorage('denied'); + } + + ngOnDestroy(): void { + this.clearBatchInterval(); + this.flushEvents(); + } + + /** @docsPrivate */ + flushEvents(): void { + if (!this.enabled || this.userConsent !== true || this.buffer.length === 0) { + return; + } + + this.sendEvents(); + } + + private initConsent(): void { + if (!this.enabled) { + return; + } + + const storedConsent = this.getConsentFromStorage(); + + if (storedConsent === 'granted') { + this.userConsent = true; + } else if (storedConsent === 'denied') { + this.userConsent = false; + } else if (this.showConsentDialog && this.poDialogService) { + this.showConsentPrompt(); + } + } + + private showConsentPrompt(): void { + const title = this.consentDialogLiterals?.title ?? 'Telemetria'; + const message = + this.consentDialogLiterals?.message ?? + 'Este aplicativo coleta dados anônimos de uso dos componentes para melhorar a experiência. Deseja permitir?'; + const confirmLabel = this.consentDialogLiterals?.confirm ?? 'Permitir'; + const cancelLabel = this.consentDialogLiterals?.cancel ?? 'Negar'; + + this.poDialogService.confirm({ + title, + message, + confirm: () => this.grantConsent(), + cancel: () => this.revokeConsent(), + literals: { + confirm: confirmLabel, + cancel: cancelLabel + } + }); + } + + private startBatchInterval(): void { + if (!this.enabled) { + return; + } + + this.ngZone.runOutsideAngular(() => { + this.intervalId = setInterval(() => this.flushEvents(), this.batchIntervalMs); + }); + } + + private clearBatchInterval(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + private sendEvents(): void { + if (!this.httpClient || !this.endpointUrl) { + return; + } + + const eventsToSend = [...this.buffer]; + this.buffer = []; + + this.httpClient.post(this.endpointUrl, eventsToSend).subscribe({ + error: () => { + this.buffer = [...eventsToSend, ...this.buffer]; + } + }); + } + + private getConsentFromStorage(): string | null { + try { + return localStorage.getItem(this.consentStorageKey); + } catch { + return null; + } + } + + private setConsentInStorage(value: string): void { + try { + localStorage.setItem(this.consentStorageKey, value); + } catch { + // Ignora erros de acesso ao localStorage (ex: modo privado em alguns navegadores) + } + } + + private generateSessionId(): string { + try { + return crypto.randomUUID(); + } catch { + return this.generateFallbackId(); + } + } + + private generateFallbackId(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); + } +} diff --git a/projects/ui/src/lib/services/services.module.ts b/projects/ui/src/lib/services/services.module.ts index e363375d48..71af8a6886 100644 --- a/projects/ui/src/lib/services/services.module.ts +++ b/projects/ui/src/lib/services/services.module.ts @@ -9,6 +9,7 @@ import { PoI18nPipe } from './po-i18n/po-i18n.pipe'; import { PoLanguageModule } from './po-language/po-language.module'; import { PoMediaQueryModule } from './po-media-query/po-media-query.module'; import { PoNotificationModule } from './po-notification/po-notification.module'; +import { PoTelemetryModule } from './po-telemetry/po-telemetry.module'; import { PoThemeModule } from './po-theme/po-theme.module'; @NgModule({ @@ -22,6 +23,7 @@ import { PoThemeModule } from './po-theme/po-theme.module'; PoLanguageModule, PoMediaQueryModule, PoNotificationModule, + PoTelemetryModule, PoThemeModule ], exports: [ @@ -33,6 +35,7 @@ import { PoThemeModule } from './po-theme/po-theme.module'; PoI18nPipe, PoMediaQueryModule, PoNotificationModule, + PoTelemetryModule, PoThemeModule ], providers: [], diff --git a/projects/ui/src/lib/utils/po-version.ts b/projects/ui/src/lib/utils/po-version.ts new file mode 100644 index 0000000000..8e6e19b407 --- /dev/null +++ b/projects/ui/src/lib/utils/po-version.ts @@ -0,0 +1 @@ +export const PO_UI_VERSION = '0.0.0-PLACEHOLDER';