From 558fa6647dc5d2ff2eda30322eea2a652a3e2ab8 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Sun, 28 Jun 2026 15:00:10 +0900 Subject: [PATCH] feat: validate product usage events against catalog --- lib/domain/entities/product_usage_event.dart | 335 +++++++++++++++++- .../bloc/schedule_form_bloc.dart | 19 +- .../product_analytics_service_test.dart | 67 ++-- .../entities/product_usage_event_test.dart | 111 ++++++ 4 files changed, 492 insertions(+), 40 deletions(-) create mode 100644 test/domain/entities/product_usage_event_test.dart diff --git a/lib/domain/entities/product_usage_event.dart b/lib/domain/entities/product_usage_event.dart index 6a243e99..4a99d087 100644 --- a/lib/domain/entities/product_usage_event.dart +++ b/lib/domain/entities/product_usage_event.dart @@ -1,14 +1,52 @@ +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; + class ProductUsageEvent { - const ProductUsageEvent({ - required this.name, - required this.workflow, - required this.result, - this.parameters = const {}, - }); + ProductUsageEvent._({ + required ProductUsageEventDefinition definition, + required ProductUsageResult eventResult, + Map parameters = const {}, + }) : name = definition.name, + workflow = definition.workflow.wireValue, + result = eventResult.wireValue, + schemaVersion = definition.schemaVersion, + parameters = ProductUsageEventCatalog.validateParameters( + definition, + parameters, + ); + + factory ProductUsageEvent.fromCatalog({ + required String name, + required ProductUsageResult result, + Map parameters = const {}, + }) { + final definition = ProductUsageEventCatalog.definitionFor(name); + return ProductUsageEvent._( + definition: definition, + eventResult: result, + parameters: parameters, + ); + } + + factory ProductUsageEvent.scheduleCreated({ + required SchedulePreparationMode? preparationMode, + required int preparationStepCount, + required int minutesUntilSchedule, + }) { + return ProductUsageEvent._( + definition: ProductUsageEventCatalog.scheduleCreated, + eventResult: ProductUsageResult.success, + parameters: { + 'preparation_mode': _preparationModeWireValue(preparationMode), + 'preparation_step_count': preparationStepCount, + 'minutes_until_schedule': minutesUntilSchedule, + }, + ); + } final String name; final String workflow; final String result; + final int schemaVersion; final Map parameters; Map toAnalyticsParameters({ @@ -16,7 +54,7 @@ class ProductUsageEvent { required String appVersion, }) { return { - 'schema_version': 1, + 'schema_version': schemaVersion, 'workflow': workflow, 'result': result, 'platform': platform, @@ -25,3 +63,286 @@ class ProductUsageEvent { }; } } + +enum ProductUsageResult { + success('success'), + failure('failure'), + allowed('allowed'), + denied('denied'), + disabled('disabled'); + + const ProductUsageResult(this.wireValue); + + final String wireValue; +} + +enum ProductUsageWorkflow { + analytics('analytics'), + onboarding('onboarding'), + authentication('authentication'), + schedule('schedule'), + notification('notification'), + alarm('alarm'); + + const ProductUsageWorkflow(this.wireValue); + + final String wireValue; +} + +enum ProductUsageEventParameterKey { + enabled('enabled'), + source('source'), + preparationStepCount('preparation_step_count'), + spareTimeMinutes('spare_time_minutes'), + authProvider('auth_provider'), + preparationMode('preparation_mode'), + minutesUntilSchedule('minutes_until_schedule'), + preparationChanged('preparation_changed'), + permissionResult('permission_result'), + launchAction('launch_action'), + provider('provider'), + errorCode('error_code'), + latenessBucket('lateness_bucket'), + startedEarly('started_early'); + + const ProductUsageEventParameterKey(this.wireValue); + + final String wireValue; + + static ProductUsageEventParameterKey? fromWireValue(String wireValue) { + for (final key in ProductUsageEventParameterKey.values) { + if (key.wireValue == wireValue) return key; + } + return null; + } +} + +class ProductUsageEventDefinition { + const ProductUsageEventDefinition({ + required this.name, + required this.workflow, + required this.allowedParameters, + this.schemaVersion = 1, + }); + + final String name; + final ProductUsageWorkflow workflow; + final Set allowedParameters; + final int schemaVersion; + + Set get allowedParameterNames => Set.unmodifiable( + allowedParameters.map((parameter) => parameter.wireValue), + ); +} + +class ProductUsageEventCatalog { + const ProductUsageEventCatalog._(); + + static const analyticsPreferenceChanged = ProductUsageEventDefinition( + name: 'analytics_preference_changed', + workflow: ProductUsageWorkflow.analytics, + allowedParameters: { + ProductUsageEventParameterKey.enabled, + ProductUsageEventParameterKey.source, + }, + ); + + static const onboardingCompleted = ProductUsageEventDefinition( + name: 'onboarding_completed', + workflow: ProductUsageWorkflow.onboarding, + allowedParameters: { + ProductUsageEventParameterKey.preparationStepCount, + ProductUsageEventParameterKey.spareTimeMinutes, + }, + ); + + static const signUpCompleted = ProductUsageEventDefinition( + name: 'sign_up_completed', + workflow: ProductUsageWorkflow.authentication, + allowedParameters: {ProductUsageEventParameterKey.authProvider}, + ); + + static const loginCompleted = ProductUsageEventDefinition( + name: 'login_completed', + workflow: ProductUsageWorkflow.authentication, + allowedParameters: {ProductUsageEventParameterKey.authProvider}, + ); + + static const scheduleCreateStarted = ProductUsageEventDefinition( + name: 'schedule_create_started', + workflow: ProductUsageWorkflow.schedule, + allowedParameters: {ProductUsageEventParameterKey.source}, + ); + + static const scheduleCreated = ProductUsageEventDefinition( + name: 'schedule_created', + workflow: ProductUsageWorkflow.schedule, + allowedParameters: { + ProductUsageEventParameterKey.preparationMode, + ProductUsageEventParameterKey.preparationStepCount, + ProductUsageEventParameterKey.minutesUntilSchedule, + }, + ); + + static const scheduleUpdated = ProductUsageEventDefinition( + name: 'schedule_updated', + workflow: ProductUsageWorkflow.schedule, + allowedParameters: { + ProductUsageEventParameterKey.preparationChanged, + ProductUsageEventParameterKey.minutesUntilSchedule, + }, + ); + + static const scheduleDeleted = ProductUsageEventDefinition( + name: 'schedule_deleted', + workflow: ProductUsageWorkflow.schedule, + allowedParameters: {ProductUsageEventParameterKey.minutesUntilSchedule}, + ); + + static const notificationPermissionResult = ProductUsageEventDefinition( + name: 'notification_permission_result', + workflow: ProductUsageWorkflow.notification, + allowedParameters: { + ProductUsageEventParameterKey.permissionResult, + ProductUsageEventParameterKey.source, + }, + ); + + static const alarmOpened = ProductUsageEventDefinition( + name: 'alarm_opened', + workflow: ProductUsageWorkflow.alarm, + allowedParameters: { + ProductUsageEventParameterKey.launchAction, + ProductUsageEventParameterKey.provider, + }, + ); + + static const alarmFailed = ProductUsageEventDefinition( + name: 'alarm_failed', + workflow: ProductUsageWorkflow.alarm, + allowedParameters: { + ProductUsageEventParameterKey.errorCode, + ProductUsageEventParameterKey.provider, + }, + ); + + static const scheduleFinished = ProductUsageEventDefinition( + name: 'schedule_finished', + workflow: ProductUsageWorkflow.schedule, + allowedParameters: { + ProductUsageEventParameterKey.latenessBucket, + ProductUsageEventParameterKey.preparationStepCount, + ProductUsageEventParameterKey.startedEarly, + }, + ); + + static const firstReleaseEvents = [ + analyticsPreferenceChanged, + onboardingCompleted, + signUpCompleted, + loginCompleted, + scheduleCreateStarted, + scheduleCreated, + scheduleUpdated, + scheduleDeleted, + notificationPermissionResult, + alarmOpened, + alarmFailed, + scheduleFinished, + ]; + + static final Map _definitionsByName = { + for (final definition in firstReleaseEvents) definition.name: definition, + }; + + static const _forbiddenParameterNames = { + 'email', + 'display_name', + 'oauth_identifier', + 'fcm_token', + 'access_token', + 'refresh_token', + 'schedule_name', + 'schedule_note', + 'place_name', + 'preparation_step_name', + 'exception', + 'stack_trace', + 'request_body', + 'response_body', + 'location', + 'latitude', + 'longitude', + }; + + static ProductUsageEventDefinition definitionFor(String name) { + final definition = _definitionsByName[name]; + if (definition == null) { + throw ProductUsageEventCatalogException( + 'Unknown Product Usage Event: $name', + ); + } + return definition; + } + + static Map validateParameters( + ProductUsageEventDefinition definition, + Map parameters, + ) { + final validatedParameters = {}; + for (final entry in parameters.entries) { + final key = entry.key; + final value = entry.value; + if (_forbiddenParameterNames.contains(key)) { + throw ProductUsageEventCatalogException( + 'Forbidden Analytics Event Parameter: $key', + ); + } + + final parameterKey = ProductUsageEventParameterKey.fromWireValue(key); + if (parameterKey == null || + !definition.allowedParameters.contains(parameterKey)) { + throw ProductUsageEventCatalogException( + 'Parameter $key is not allowed for ${definition.name}', + ); + } + + _validateParameterValue(key, value); + validatedParameters[key] = value; + } + return Map.unmodifiable(validatedParameters); + } + + static void _validateParameterValue(String key, Object value) { + if (value is Map || value is Iterable) { + throw ProductUsageEventCatalogException( + 'Parameter $key must be a scalar analytics value', + ); + } + if (value is String || value is num || value is bool) return; + throw ProductUsageEventCatalogException( + 'Parameter $key has unsupported value type ${value.runtimeType}', + ); + } +} + +class ProductUsageEventCatalogException implements Exception { + const ProductUsageEventCatalogException(this.message); + + final String message; + + @override + String toString() => message; +} + +String _preparationModeWireValue(SchedulePreparationMode? mode) { + switch (mode) { + case SchedulePreparationMode.template: + return 'template'; + case SchedulePreparationMode.custom: + return 'custom'; + case SchedulePreparationMode.defaultPreparation: + case null: + return 'default'; + } +} diff --git a/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart b/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart index a0932ed8..78aae5bf 100644 --- a/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart +++ b/lib/presentation/schedule_create/bloc/schedule_form_bloc.dart @@ -313,18 +313,13 @@ class ScheduleFormBloc extends Bloc { Future _trackScheduleCreated(ScheduleEntity scheduleEntity) async { await _productUsageEventTracker.track( - ProductUsageEvent( - name: 'schedule_created', - workflow: 'schedule', - result: 'success', - parameters: { - 'preparation_mode': scheduleEntity.preparationMode?.name ?? 'default', - 'preparation_step_count': - state.preparation?.preparationStepList.length ?? 0, - 'minutes_until_schedule': scheduleEntity.scheduleTime - .difference(DateTime.now()) - .inMinutes, - }, + ProductUsageEvent.scheduleCreated( + preparationMode: scheduleEntity.preparationMode, + preparationStepCount: + state.preparation?.preparationStepList.length ?? 0, + minutesUntilSchedule: scheduleEntity.scheduleTime + .difference(DateTime.now()) + .inMinutes, ), ); } diff --git a/test/core/services/product_analytics_service_test.dart b/test/core/services/product_analytics_service_test.dart index 83c63c33..118cb3ae 100644 --- a/test/core/services/product_analytics_service_test.dart +++ b/test/core/services/product_analytics_service_test.dart @@ -2,29 +2,54 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:on_time_front/core/services/product_analytics_service.dart'; import 'package:on_time_front/domain/entities/analytics_preference.dart'; import 'package:on_time_front/domain/entities/product_usage_event.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; void main() { - test('unconfirmed analytics preference does not log product usage events', () async { - final client = _FakeAnalyticsProviderClient(); - final service = ProductAnalyticsService( - client: client, - collectionAllowedInBuild: true, - ); - - await service.applyPreference( - const AnalyticsPreference(enabled: true, isConfirmed: false), - ); - await service.track( - const ProductUsageEvent( - name: 'schedule_created', - workflow: 'schedule', - result: 'success', - ), - ); - - expect(client.collectionEnabledValues, [false]); - expect(client.loggedEvents, isEmpty); - }); + test( + 'unconfirmed analytics preference does not log product usage events', + () async { + final client = _FakeAnalyticsProviderClient(); + final service = ProductAnalyticsService( + client: client, + collectionAllowedInBuild: true, + ); + + await service.applyPreference( + const AnalyticsPreference(enabled: true, isConfirmed: false), + ); + await service.track(_scheduleCreatedEvent()); + + expect(client.collectionEnabledValues, [false]); + expect(client.loggedEvents, isEmpty); + }, + ); + + test( + 'disabled analytics preference does not log product usage events', + () async { + final client = _FakeAnalyticsProviderClient(); + final service = ProductAnalyticsService( + client: client, + collectionAllowedInBuild: true, + ); + + await service.applyPreference( + const AnalyticsPreference(enabled: false, isConfirmed: true), + ); + await service.track(_scheduleCreatedEvent()); + + expect(client.collectionEnabledValues, [false]); + expect(client.loggedEvents, isEmpty); + }, + ); +} + +ProductUsageEvent _scheduleCreatedEvent() { + return ProductUsageEvent.scheduleCreated( + preparationMode: SchedulePreparationMode.defaultPreparation, + preparationStepCount: 1, + minutesUntilSchedule: 60, + ); } class _FakeAnalyticsProviderClient implements AnalyticsProviderClient { diff --git a/test/domain/entities/product_usage_event_test.dart b/test/domain/entities/product_usage_event_test.dart new file mode 100644 index 00000000..6db20693 --- /dev/null +++ b/test/domain/entities/product_usage_event_test.dart @@ -0,0 +1,111 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:on_time_front/domain/entities/product_usage_event.dart'; +import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart'; + +void main() { + test('schedule_created factory builds the catalog event', () { + final event = ProductUsageEvent.scheduleCreated( + preparationMode: SchedulePreparationMode.custom, + preparationStepCount: 3, + minutesUntilSchedule: 45, + ); + + expect(event.name, 'schedule_created'); + expect(event.workflow, 'schedule'); + expect(event.result, 'success'); + expect(event.parameters, { + 'preparation_mode': 'custom', + 'preparation_step_count': 3, + 'minutes_until_schedule': 45, + }); + expect(event.toAnalyticsParameters(platform: 'ios', appVersion: '1.2.3'), { + 'schema_version': 1, + 'workflow': 'schedule', + 'result': 'success', + 'platform': 'ios', + 'app_version': '1.2.3', + 'preparation_mode': 'custom', + 'preparation_step_count': 3, + 'minutes_until_schedule': 45, + }); + }); + + test('catalog rejects unknown product usage events', () { + expect( + () => ProductUsageEvent.fromCatalog( + name: 'raw_button_clicked', + result: ProductUsageResult.success, + ), + throwsA(isA()), + ); + }); + + test('catalog rejects parameters that are not allowlisted for the event', () { + expect( + () => ProductUsageEvent.fromCatalog( + name: 'schedule_created', + result: ProductUsageResult.success, + parameters: {'auth_provider': 'google'}, + ), + throwsA(isA()), + ); + }); + + test('catalog rejects forbidden privacy-sensitive fields', () { + expect( + () => ProductUsageEvent.fromCatalog( + name: 'schedule_created', + result: ProductUsageResult.success, + parameters: {'schedule_note': 'leave early'}, + ), + throwsA(isA()), + ); + }); + + test('catalog rejects arbitrary nested map parameter values', () { + expect( + () => ProductUsageEvent.fromCatalog( + name: 'schedule_created', + result: ProductUsageResult.success, + parameters: { + 'preparation_mode': {'raw': 'custom'}, + }, + ), + throwsA(isA()), + ); + }); + + test('catalog matches the documented first-release events', () { + final documentedCatalog = _documentedEventCatalog(); + final codeCatalog = { + for (final event in ProductUsageEventCatalog.firstReleaseEvents) + event.name: event.allowedParameterNames.toList()..sort(), + }; + + expect(codeCatalog, documentedCatalog); + }); +} + +Map> _documentedEventCatalog() { + final catalog = >{}; + final rows = File( + 'docs/Analytics-Event-Catalog.md', + ).readAsLinesSync().where((line) => line.startsWith('| `')); + + for (final row in rows) { + final cells = row.split('|').map((cell) => cell.trim()).toList(); + if (cells.length < 6) continue; + final eventName = _backtickValues(cells[1]).single; + final parameterNames = _backtickValues(cells[4]).toList()..sort(); + catalog[eventName] = parameterNames; + } + return catalog; +} + +Iterable _backtickValues(String markdown) { + return RegExp( + r'`([^`]+)`', + ).allMatches(markdown).map((match) => match.group(1)!); +}