Skip to content

Adding more information to LogFileStats + minor updates to tests #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Mar 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pkgs/unified_analytics/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## 0.1.1-dev
## 0.1.1

- Bumping intl package to 0.18.0 to fix version solving issue with flutter_tools
- LogFileStats includes more information about how many events are persisted and total count of how many times each event was sent

## 0.1.0

Expand Down
22 changes: 17 additions & 5 deletions pkgs/unified_analytics/USAGE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,18 +143,30 @@ print(analytics.logFileStats());

// Prints out the below
// {
// "startDateTime": "2023-02-08 15:07:10.293728",
// "endDateTime": "2023-02-08 15:07:10.299678",
// "sessionCount": 1,
// "flutterChannelCount": 1,
// "toolCount": 1
// "startDateTime": "2023-02-22 15:23:24.410921",
// "minsFromStartDateTime": 20319,
// "endDateTime": "2023-03-08 15:46:36.318211",
// "minsFromEndDateTime": 136,
// "sessionCount": 7,
// "flutterChannelCount": 2,
// "toolCount": 1,
// "recordCount": 23,
// "eventCount": {
// "hot_reload_time": 16,
// "analytics_collection_enabled": 7,
// ... scales up with number of events
// }
// }
```

Explanation of the each key above

- startDateTime: the earliest event that was sent
- minsFromStartDateTime: the number of minutes elapsed since the earliest message
- endDateTime: the latest, most recent event that was sent
- minsFromEndDateTime: the number of minutes elapsed since the latest message
- sessionCount: count of sessions; sessions have a minimum time of 30 minutes
- flutterChannelCount: count of flutter channels (can be 0 if developer is a Dart dev only)
- toolCount: count of the Dart and Flutter tools sending analytics
- recordCount: count of the total number of events in the log file
- eventCount: counts each unique event and how many times they occurred in the log file
11 changes: 9 additions & 2 deletions pkgs/unified_analytics/lib/src/analytics.dart
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,14 @@ class AnalyticsImpl implements Analytics {
required DashEvent eventName,
Map<String, Object?> eventData = const {},
}) {
if (!telemetryEnabled) return null;
// Checking the [telemetryEnabled] boolean reflects what the
// config file reflects
//
// Checking the [_showMessage] boolean indicates if this the first
// time the tool is using analytics or if there has been an update
// the messaging found in constants.dart - in both cases, analytics
// will not be sent until the second time the tool is used
if (!telemetryEnabled || _showMessage) return null;

// Construct the body of the request
final Map<String, Object?> body = generateRequestBody(
Expand Down Expand Up @@ -311,7 +318,7 @@ class TestAnalytics extends AnalyticsImpl {
required DashEvent eventName,
Map<String, Object?> eventData = const {},
}) {
if (!telemetryEnabled) return null;
if (!telemetryEnabled || _showMessage) return null;

// Calling the [generateRequestBody] method will ensure that the
// session file is getting updated without actually making any
Expand Down
2 changes: 1 addition & 1 deletion pkgs/unified_analytics/lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const int kLogFileLength = 2500;
const String kLogFileName = 'dash-analytics.log';

/// The current version of the package, should be in line with pubspec version.
const String kPackageVersion = '0.1.1-dev';
const String kPackageVersion = '0.1.1';

/// The minimum length for a session
const int kSessionDurationMinutes = 30;
Expand Down
62 changes: 58 additions & 4 deletions pkgs/unified_analytics/lib/src/log_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import 'dart:convert';

import 'package:clock/clock.dart';
import 'package:file/file.dart';
import 'package:path/path.dart' as p;

Expand All @@ -16,9 +17,15 @@ class LogFileStats {
/// The oldest timestamp in the log file
final DateTime startDateTime;

/// Number of minutes from [startDateTime] to [clock.now()]
final int minsFromStartDateTime;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about just storing startDateTime and endDateTime as ints, either from millisecondsSinceEpoch or microsecondsSinceEpoch?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh shoot, I totally forgot that I'm using LogFileStats as a data class essentially. It only appears as a string when you print, or invoke toString() on the entire class. And that was really just for debugging when developing.

So really, when we perform operations with these value, we don't even convert it from ints into DateTime. Thoughts on still leaving it this way?

  @override
  String toString() => jsonEncode(<String, Object?>{
        'startDateTime': startDateTime.toString(),
        'minsFromStartDateTime': minsFromStartDateTime,
        'endDateTime': endDateTime.toString(),
        'minsFromEndDateTime': minsFromEndDateTime,
        'sessionCount': sessionCount,
        'flutterChannelCount': flutterChannelCount,
        'toolCount': toolCount,
        'recordCount': recordCount,
        'eventCount': eventCount,
      });

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh ok, in that case keeping it a DateTime makes sense. However, at what point do we serialize this to disk, and in what format are we serializing these timestamps?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we don't actually serialize anything after the log file stats have been created. This data class will instead be used for checking conditions for a survey and returning a Boolean to show the survey or not.

But these logs are persisted before this step on the user's machine in the same form that they are sent to GA. And in the local log file, it is being persisted as a date time string so that we know the time the user sent the event in their locale. GA also assigns its own time stamp in milliseconds since epoch time in UTC so that all events can be compared chronologically.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's never serialized, how is it part of userProps https://github.com/dart-lang/tools/blob/main/pkgs/unified_analytics/lib/src/log_handler.dart#L226? Isn't that map being read from disk?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the workflow starts with an event sent, and the events usually look like the below being sent in the body of a POST request

{
  "client_id": "46cc0ba6-f604-4fd9-aa2f-8a20beb24cd4",
  "events": [{ "name": "testing", "params": { "time_ns": 345 } }],
  "user_properties": {
    "session_id": { "value": 1673466750423 },
    "flutter_channel": { "value": "ey-test-channel" },
    "host": { "value": "macos" },
    "flutter_version": { "value": "Flutter 3.6.0-7.0.pre.47" },
    "dart_version": { "value": "Dart 2.19.0" },
    "tool": { "value": "flutter-tools" },
    "local_time": { "value": "2023-01-11 14:53:31.471816" }
  }
}

In the above, we do serialize the DateTime to a string in the form yyyy-mm-dd hh:mm:ss.ffff which is the default toString output for a datetime. This is so in GA and BigQuery, we can look at the table and understand the data easily and deserializing within BigQuery is trivial.

Once the event is sent, the entire payload of the POST request is saved into the log file. It is at this point that we read it in and deserialize the string back into a DateTime.

After we have created the LogFileStats data class, we don't serialize that object again. Once we have survey conditions, we will do checks within dart code.

final LogFileStats query = _logHandler.logFileStats();

if (query.toolCount > 2) {
  print('The user has used more than 2 tools');
}

It is only when we print out the query object from above that the data in the class gets serialized again to a string, and this is really for debugging purposes that I included that toString method for LogFileStats

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh ok, never mind, I misread what you said, you said it is persisted because we specifically want it in the user's locale? So GA is expecting a localized string?

Copy link
Contributor Author

@eliasyishak eliasyishak Mar 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, it is. And that's because GA already assigns a timestamp for the event which is in UTC and in milliseconds since epoch time

Also it's not really "expecting" it, we defined the schema of the table to be that way

image

The below also shows how we store the user props data with the local time

image


/// The latest timestamp in the log file
final DateTime endDateTime;

/// Number of minutes from [endDateTime] to [clock.now()]
final int minsFromEndDateTime;

/// The number of unique session ids found in the log file
final int sessionCount;

Expand All @@ -28,22 +35,37 @@ class LogFileStats {
/// The number of unique tools found in the log file
final int toolCount;

/// The map containing all of the events in the file along with
/// how many times they have occured
final Map<String, int> eventCount;

/// Total number of records in the log file
final int recordCount;

/// Contains the data from the [LogHandler.logFileStats] method
const LogFileStats({
required this.startDateTime,
required this.minsFromStartDateTime,
required this.endDateTime,
required this.minsFromEndDateTime,
required this.sessionCount,
required this.flutterChannelCount,
required this.toolCount,
required this.recordCount,
required this.eventCount,
});

@override
String toString() => jsonEncode(<String, Object?>{
'startDateTime': startDateTime.toString(),
'minsFromStartDateTime': minsFromStartDateTime,
'endDateTime': endDateTime.toString(),
'minsFromEndDateTime': minsFromEndDateTime,
'sessionCount': sessionCount,
'flutterChannelCount': flutterChannelCount,
'toolCount': toolCount,
'recordCount': recordCount,
'eventCount': eventCount,
});
}

Expand Down Expand Up @@ -89,26 +111,42 @@ class LogHandler {
final DateTime startDateTime = records.first.localTime;
final DateTime endDateTime = records.last.localTime;

// Collection of unique sessions
// Map with counters for user properties
final Map<String, Set<Object>> counter = <String, Set<Object>>{
'sessions': <int>{},
'flutter_channel': <String>{},
'tool': <String>{},
};

// Map of counters for each event
final Map<String, int> eventCount = <String, int>{};
for (LogItem record in records) {
counter['sessions']!.add(record.sessionId);
counter['tool']!.add(record.tool);
if (record.flutterChannel != null) {
counter['flutter_channel']!.add(record.flutterChannel!);
}

// Count each event, if it doesn't exist in the [eventCount]
// it will be added first
if (!eventCount.containsKey(record.eventName)) {
eventCount[record.eventName] = 0;
}
eventCount[record.eventName] = eventCount[record.eventName]! + 1;
}

final DateTime now = clock.now();

return LogFileStats(
startDateTime: startDateTime,
minsFromStartDateTime: now.difference(startDateTime).inMinutes,
endDateTime: endDateTime,
minsFromEndDateTime: now.difference(endDateTime).inMinutes,
sessionCount: counter['sessions']!.length,
flutterChannelCount: counter['flutter_channel']!.length,
toolCount: counter['tool']!.length,
eventCount: eventCount,
recordCount: records.length,
);
}

Expand All @@ -135,6 +173,7 @@ class LogHandler {

/// Data class for each record persisted on the client's machine
class LogItem {
final String eventName;
final int sessionId;
final String? flutterChannel;
final String host;
Expand All @@ -144,6 +183,7 @@ class LogItem {
final DateTime localTime;

LogItem({
required this.eventName,
required this.sessionId,
this.flutterChannel,
required this.host,
Expand Down Expand Up @@ -194,18 +234,27 @@ class LogItem {
/// "value": "flutter-tools"
/// },
/// "local_time": {
/// "value": "2023-01-31 14:32:14.592898"
/// "value": "2023-01-31 14:32:14.592898 -0500"
/// }
/// }
/// }
/// ```
static LogItem? fromRecord(Map<String, Object?> record) {
if (!record.containsKey('user_properties')) return null;
if (!record.containsKey('user_properties') ||
!record.containsKey('events')) {
return null;
}

// Using a try/except here to parse out the fields if possible,
// if not, it will quietly return null and won't get processed
// downstream
try {
// Parse out values from the top level key = 'events' and return
// a map for the one event in the value
final Map<String, Object?> eventProp =
((record['events']! as List<Object?>).first as Map<String, Object?>);
final String eventName = eventProp['name'] as String;

// Parse the data out of the `user_properties` value
final Map<String, Object?> userProps =
record['user_properties'] as Map<String, Object?>;
Expand All @@ -230,6 +279,10 @@ class LogItem {
// indicates the record is malformed; note that `flutter_version`
// and `flutter_channel` are nullable fields in the log file
final List<Object?> values = <Object?>[
// Values associated with the top level key = 'events'
eventName,

// Values associated with the top level key = 'events'
sessionId,
host,
dartVersion,
Expand All @@ -241,9 +294,10 @@ class LogItem {
}

// Parse the local time from the string extracted
final DateTime localTime = DateTime.parse(localTimeString!);
final DateTime localTime = DateTime.parse(localTimeString!).toLocal();

return LogItem(
eventName: eventName,
sessionId: sessionId!,
flutterChannel: flutterChannel,
host: host!,
Expand Down
3 changes: 2 additions & 1 deletion pkgs/unified_analytics/lib/src/user_property.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:clock/clock.dart';

import 'constants.dart';
import 'session.dart';
import 'utils.dart';

class UserProperty {
final Session session;
Expand Down Expand Up @@ -58,6 +59,6 @@ class UserProperty {
'dart_version': dartVersion,
'analytics_pkg_version': kPackageVersion,
'tool': tool,
'local_time': '${clock.now()}',
'local_time': formatDateTime(clock.now()),
};
}
17 changes: 16 additions & 1 deletion pkgs/unified_analytics/lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ import 'package:file/file.dart';
import 'enums.dart';
import 'user_property.dart';

/// Format time as 'yyyy-MM-dd HH:mm:ss Z' where Z is the difference between the
/// timezone of t and UTC formatted according to RFC 822.
String formatDateTime(DateTime t) {
final String sign = t.timeZoneOffset.isNegative ? '-' : '+';
final Duration tzOffset = t.timeZoneOffset.abs();
final int hoursOffset = tzOffset.inHours;
final int minutesOffset =
tzOffset.inMinutes - (Duration.minutesPerHour * hoursOffset);
assert(hoursOffset < 24);
assert(minutesOffset < 60);

String twoDigits(int n) => (n >= 10) ? '$n' : '0$n';
return '$t $sign${twoDigits(hoursOffset)}${twoDigits(minutesOffset)}';
}

/// Construct the Map that will be converted to json for the
/// body of the request
///
Expand All @@ -26,7 +41,7 @@ import 'user_property.dart';
/// "flutter_version": { "value": "Flutter 3.6.0-7.0.pre.47" },
/// "dart_version": { "value": "Dart 2.19.0" },
/// "tool": { "value": "flutter-tools" },
/// "local_time": { "value": "2023-01-11 14:53:31.471816" }
/// "local_time": { "value": "2023-01-11 14:53:31.471816 -0500" }
/// }
/// }
/// ```
Expand Down
2 changes: 1 addition & 1 deletion pkgs/unified_analytics/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: >-
to Google Analytics.
# When updating this, keep the version consistent with the changelog and the
# value in lib/src/constants.dart.
version: 0.1.1-dev
version: 0.1.1
repository: https://github.com/dart-lang/tools/tree/main/pkgs/unified_analytics

environment:
Expand Down
Loading