Skip to content

Commit cc18236

Browse files
authored
Merge pull request #570 from DevKor-github/feature/issue-548-product-usage-event-catalog
Validate ProductUsageEvent against analytics catalog
2 parents 4e39d10 + b6e42e0 commit cc18236

4 files changed

Lines changed: 484 additions & 46 deletions

File tree

Lines changed: 328 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,60 @@
1+
import 'package:on_time_front/domain/entities/schedule_preparation_mode.dart';
2+
13
class ProductUsageEvent {
2-
const ProductUsageEvent({
3-
required this.name,
4-
required this.workflow,
5-
required this.result,
6-
this.parameters = const {},
7-
});
4+
ProductUsageEvent._({
5+
required ProductUsageEventDefinition definition,
6+
required ProductUsageResult eventResult,
7+
Map<String, Object> parameters = const {},
8+
}) : name = definition.name,
9+
workflow = definition.workflow.wireValue,
10+
result = eventResult.wireValue,
11+
schemaVersion = definition.schemaVersion,
12+
parameters = ProductUsageEventCatalog.validateParameters(
13+
definition,
14+
parameters,
15+
);
16+
17+
factory ProductUsageEvent.fromCatalog({
18+
required String name,
19+
required ProductUsageResult result,
20+
Map<String, Object> parameters = const {},
21+
}) {
22+
final definition = ProductUsageEventCatalog.definitionFor(name);
23+
return ProductUsageEvent._(
24+
definition: definition,
25+
eventResult: result,
26+
parameters: parameters,
27+
);
28+
}
29+
30+
factory ProductUsageEvent.scheduleCreated({
31+
required SchedulePreparationMode? preparationMode,
32+
required int preparationStepCount,
33+
required int minutesUntilSchedule,
34+
}) {
35+
return ProductUsageEvent._(
36+
definition: ProductUsageEventCatalog.scheduleCreated,
37+
eventResult: ProductUsageResult.success,
38+
parameters: {
39+
'preparation_mode': _preparationModeWireValue(preparationMode),
40+
'preparation_step_count': preparationStepCount,
41+
'minutes_until_schedule': minutesUntilSchedule,
42+
},
43+
);
44+
}
845

946
final String name;
1047
final String workflow;
1148
final String result;
49+
final int schemaVersion;
1250
final Map<String, Object> parameters;
1351

1452
Map<String, Object> toAnalyticsParameters({
1553
required String platform,
1654
required String appVersion,
1755
}) {
1856
return {
19-
'schema_version': 1,
57+
'schema_version': schemaVersion,
2058
'workflow': workflow,
2159
'result': result,
2260
'platform': platform,
@@ -25,3 +63,286 @@ class ProductUsageEvent {
2563
};
2664
}
2765
}
66+
67+
enum ProductUsageResult {
68+
success('success'),
69+
failure('failure'),
70+
allowed('allowed'),
71+
denied('denied'),
72+
disabled('disabled');
73+
74+
const ProductUsageResult(this.wireValue);
75+
76+
final String wireValue;
77+
}
78+
79+
enum ProductUsageWorkflow {
80+
analytics('analytics'),
81+
onboarding('onboarding'),
82+
authentication('authentication'),
83+
schedule('schedule'),
84+
notification('notification'),
85+
alarm('alarm');
86+
87+
const ProductUsageWorkflow(this.wireValue);
88+
89+
final String wireValue;
90+
}
91+
92+
enum ProductUsageEventParameterKey {
93+
enabled('enabled'),
94+
source('source'),
95+
preparationStepCount('preparation_step_count'),
96+
spareTimeMinutes('spare_time_minutes'),
97+
authProvider('auth_provider'),
98+
preparationMode('preparation_mode'),
99+
minutesUntilSchedule('minutes_until_schedule'),
100+
preparationChanged('preparation_changed'),
101+
permissionResult('permission_result'),
102+
launchAction('launch_action'),
103+
provider('provider'),
104+
errorCode('error_code'),
105+
latenessBucket('lateness_bucket'),
106+
startedEarly('started_early');
107+
108+
const ProductUsageEventParameterKey(this.wireValue);
109+
110+
final String wireValue;
111+
112+
static ProductUsageEventParameterKey? fromWireValue(String wireValue) {
113+
for (final key in ProductUsageEventParameterKey.values) {
114+
if (key.wireValue == wireValue) return key;
115+
}
116+
return null;
117+
}
118+
}
119+
120+
class ProductUsageEventDefinition {
121+
const ProductUsageEventDefinition({
122+
required this.name,
123+
required this.workflow,
124+
required this.allowedParameters,
125+
this.schemaVersion = 1,
126+
});
127+
128+
final String name;
129+
final ProductUsageWorkflow workflow;
130+
final Set<ProductUsageEventParameterKey> allowedParameters;
131+
final int schemaVersion;
132+
133+
Set<String> get allowedParameterNames => Set.unmodifiable(
134+
allowedParameters.map((parameter) => parameter.wireValue),
135+
);
136+
}
137+
138+
class ProductUsageEventCatalog {
139+
const ProductUsageEventCatalog._();
140+
141+
static const analyticsPreferenceChanged = ProductUsageEventDefinition(
142+
name: 'analytics_preference_changed',
143+
workflow: ProductUsageWorkflow.analytics,
144+
allowedParameters: {
145+
ProductUsageEventParameterKey.enabled,
146+
ProductUsageEventParameterKey.source,
147+
},
148+
);
149+
150+
static const onboardingCompleted = ProductUsageEventDefinition(
151+
name: 'onboarding_completed',
152+
workflow: ProductUsageWorkflow.onboarding,
153+
allowedParameters: {
154+
ProductUsageEventParameterKey.preparationStepCount,
155+
ProductUsageEventParameterKey.spareTimeMinutes,
156+
},
157+
);
158+
159+
static const signUpCompleted = ProductUsageEventDefinition(
160+
name: 'sign_up_completed',
161+
workflow: ProductUsageWorkflow.authentication,
162+
allowedParameters: {ProductUsageEventParameterKey.authProvider},
163+
);
164+
165+
static const loginCompleted = ProductUsageEventDefinition(
166+
name: 'login_completed',
167+
workflow: ProductUsageWorkflow.authentication,
168+
allowedParameters: {ProductUsageEventParameterKey.authProvider},
169+
);
170+
171+
static const scheduleCreateStarted = ProductUsageEventDefinition(
172+
name: 'schedule_create_started',
173+
workflow: ProductUsageWorkflow.schedule,
174+
allowedParameters: {ProductUsageEventParameterKey.source},
175+
);
176+
177+
static const scheduleCreated = ProductUsageEventDefinition(
178+
name: 'schedule_created',
179+
workflow: ProductUsageWorkflow.schedule,
180+
allowedParameters: {
181+
ProductUsageEventParameterKey.preparationMode,
182+
ProductUsageEventParameterKey.preparationStepCount,
183+
ProductUsageEventParameterKey.minutesUntilSchedule,
184+
},
185+
);
186+
187+
static const scheduleUpdated = ProductUsageEventDefinition(
188+
name: 'schedule_updated',
189+
workflow: ProductUsageWorkflow.schedule,
190+
allowedParameters: {
191+
ProductUsageEventParameterKey.preparationChanged,
192+
ProductUsageEventParameterKey.minutesUntilSchedule,
193+
},
194+
);
195+
196+
static const scheduleDeleted = ProductUsageEventDefinition(
197+
name: 'schedule_deleted',
198+
workflow: ProductUsageWorkflow.schedule,
199+
allowedParameters: {ProductUsageEventParameterKey.minutesUntilSchedule},
200+
);
201+
202+
static const notificationPermissionResult = ProductUsageEventDefinition(
203+
name: 'notification_permission_result',
204+
workflow: ProductUsageWorkflow.notification,
205+
allowedParameters: {
206+
ProductUsageEventParameterKey.permissionResult,
207+
ProductUsageEventParameterKey.source,
208+
},
209+
);
210+
211+
static const alarmOpened = ProductUsageEventDefinition(
212+
name: 'alarm_opened',
213+
workflow: ProductUsageWorkflow.alarm,
214+
allowedParameters: {
215+
ProductUsageEventParameterKey.launchAction,
216+
ProductUsageEventParameterKey.provider,
217+
},
218+
);
219+
220+
static const alarmFailed = ProductUsageEventDefinition(
221+
name: 'alarm_failed',
222+
workflow: ProductUsageWorkflow.alarm,
223+
allowedParameters: {
224+
ProductUsageEventParameterKey.errorCode,
225+
ProductUsageEventParameterKey.provider,
226+
},
227+
);
228+
229+
static const scheduleFinished = ProductUsageEventDefinition(
230+
name: 'schedule_finished',
231+
workflow: ProductUsageWorkflow.schedule,
232+
allowedParameters: {
233+
ProductUsageEventParameterKey.latenessBucket,
234+
ProductUsageEventParameterKey.preparationStepCount,
235+
ProductUsageEventParameterKey.startedEarly,
236+
},
237+
);
238+
239+
static const firstReleaseEvents = <ProductUsageEventDefinition>[
240+
analyticsPreferenceChanged,
241+
onboardingCompleted,
242+
signUpCompleted,
243+
loginCompleted,
244+
scheduleCreateStarted,
245+
scheduleCreated,
246+
scheduleUpdated,
247+
scheduleDeleted,
248+
notificationPermissionResult,
249+
alarmOpened,
250+
alarmFailed,
251+
scheduleFinished,
252+
];
253+
254+
static final Map<String, ProductUsageEventDefinition> _definitionsByName = {
255+
for (final definition in firstReleaseEvents) definition.name: definition,
256+
};
257+
258+
static const _forbiddenParameterNames = <String>{
259+
'email',
260+
'display_name',
261+
'oauth_identifier',
262+
'fcm_token',
263+
'access_token',
264+
'refresh_token',
265+
'schedule_name',
266+
'schedule_note',
267+
'place_name',
268+
'preparation_step_name',
269+
'exception',
270+
'stack_trace',
271+
'request_body',
272+
'response_body',
273+
'location',
274+
'latitude',
275+
'longitude',
276+
};
277+
278+
static ProductUsageEventDefinition definitionFor(String name) {
279+
final definition = _definitionsByName[name];
280+
if (definition == null) {
281+
throw ProductUsageEventCatalogException(
282+
'Unknown Product Usage Event: $name',
283+
);
284+
}
285+
return definition;
286+
}
287+
288+
static Map<String, Object> validateParameters(
289+
ProductUsageEventDefinition definition,
290+
Map<String, Object> parameters,
291+
) {
292+
final validatedParameters = <String, Object>{};
293+
for (final entry in parameters.entries) {
294+
final key = entry.key;
295+
final value = entry.value;
296+
if (_forbiddenParameterNames.contains(key)) {
297+
throw ProductUsageEventCatalogException(
298+
'Forbidden Analytics Event Parameter: $key',
299+
);
300+
}
301+
302+
final parameterKey = ProductUsageEventParameterKey.fromWireValue(key);
303+
if (parameterKey == null ||
304+
!definition.allowedParameters.contains(parameterKey)) {
305+
throw ProductUsageEventCatalogException(
306+
'Parameter $key is not allowed for ${definition.name}',
307+
);
308+
}
309+
310+
_validateParameterValue(key, value);
311+
validatedParameters[key] = value;
312+
}
313+
return Map.unmodifiable(validatedParameters);
314+
}
315+
316+
static void _validateParameterValue(String key, Object value) {
317+
if (value is Map || value is Iterable) {
318+
throw ProductUsageEventCatalogException(
319+
'Parameter $key must be a scalar analytics value',
320+
);
321+
}
322+
if (value is String || value is num || value is bool) return;
323+
throw ProductUsageEventCatalogException(
324+
'Parameter $key has unsupported value type ${value.runtimeType}',
325+
);
326+
}
327+
}
328+
329+
class ProductUsageEventCatalogException implements Exception {
330+
const ProductUsageEventCatalogException(this.message);
331+
332+
final String message;
333+
334+
@override
335+
String toString() => message;
336+
}
337+
338+
String _preparationModeWireValue(SchedulePreparationMode? mode) {
339+
switch (mode) {
340+
case SchedulePreparationMode.template:
341+
return 'template';
342+
case SchedulePreparationMode.custom:
343+
return 'custom';
344+
case SchedulePreparationMode.defaultPreparation:
345+
case null:
346+
return 'default';
347+
}
348+
}

lib/presentation/schedule_create/bloc/schedule_form_bloc.dart

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -338,18 +338,13 @@ class ScheduleFormBloc extends Bloc<ScheduleFormEvent, ScheduleFormState> {
338338

339339
Future<void> _trackScheduleCreated(ScheduleEntity scheduleEntity) async {
340340
await _productUsageEventTracker.track(
341-
ProductUsageEvent(
342-
name: 'schedule_created',
343-
workflow: 'schedule',
344-
result: 'success',
345-
parameters: {
346-
'preparation_mode': scheduleEntity.preparationMode?.name ?? 'default',
347-
'preparation_step_count':
348-
state.preparation?.preparationStepList.length ?? 0,
349-
'minutes_until_schedule': scheduleEntity.scheduleTime
350-
.difference(DateTime.now())
351-
.inMinutes,
352-
},
341+
ProductUsageEvent.scheduleCreated(
342+
preparationMode: scheduleEntity.preparationMode,
343+
preparationStepCount:
344+
state.preparation?.preparationStepList.length ?? 0,
345+
minutesUntilSchedule: scheduleEntity.scheduleTime
346+
.difference(DateTime.now())
347+
.inMinutes,
353348
),
354349
);
355350
}

0 commit comments

Comments
 (0)