Skip to content

Commit 23fac56

Browse files
Send analysis event when running code generator (#41)
1 parent 0fbf8c7 commit 23fac56

File tree

11 files changed

+468
-0
lines changed

11 files changed

+468
-0
lines changed

generator/lib/assets/.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# See analysis_test.dart on how to create the file.
2+
# Note: while not added to Git, this file is published to pub.dev: see .pubignore.
3+
analysis-token.txt

generator/lib/assets/.pubignore

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Override .gitignore of this directory for pub.dev publishing.
2+
3+
# Add no rules to publish analysis-token.txt to pub.dev so analysis works on releases.
+173
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
import 'dart:isolate';
4+
5+
import 'package:cryptography/cryptography.dart';
6+
import 'package:http/http.dart' as http;
7+
import 'package:objectbox_generator/src/analysis/build_properties.dart';
8+
import 'package:pubspec_parse/pubspec_parse.dart';
9+
10+
/// Sends anonymous data to analyze usage of this package.
11+
///
12+
/// Requires [tokenFilePath] to exist, otherwise does nothing. See the
13+
/// associated test (analysis_test.dart) on how to create this file.
14+
class ObjectBoxAnalysis {
15+
static const _debug = false;
16+
17+
/// Path is relative to lib folder.
18+
static const tokenFilePath = "assets/analysis-token.txt";
19+
20+
static const _url = "api.mixpanel.com";
21+
static const _path = "track";
22+
23+
/// Builds a Build event and sends it with [sendEvent]. May not send if it
24+
/// fails to store a unique identifier and last time sent, or if no valid API
25+
/// token is found.
26+
Future<void> sendBuildEvent(Pubspec? pubspec) async {
27+
var buildProperties = await BuildProperties.get();
28+
if (buildProperties == null) {
29+
buildProperties = BuildProperties.create();
30+
} else {
31+
// Send at most one event per day.
32+
if (DateTime.now().millisecondsSinceEpoch <
33+
buildProperties.lastSentMs + Duration(days: 1).inMilliseconds) {
34+
if (_debug) {
35+
print("[ObjectBox] Analysis event sent within last day, skip.");
36+
}
37+
return;
38+
}
39+
buildProperties = BuildProperties.updateLastSentMs(buildProperties);
40+
}
41+
if (!await buildProperties.write()) {
42+
if (_debug) {
43+
print("[ObjectBox] Analysis failed to save build properties.");
44+
}
45+
return;
46+
}
47+
48+
final event = buildEvent("Build", buildProperties.uid, pubspec);
49+
50+
final response = await sendEvent(event);
51+
if (_debug && response != null) {
52+
print(
53+
"[ObjectBox] Analysis response: ${response.statusCode} ${response.body}");
54+
}
55+
}
56+
57+
/// Sends an [Event] and returns the response. May return null if the API
58+
/// token could not be obtained.
59+
Future<http.Response?> sendEvent(Event event) async {
60+
final token = await _getToken();
61+
if (token == null || token.isEmpty) {
62+
print("[ObjectBox] Analysis disabled, would have sent event: $event");
63+
return null;
64+
}
65+
event.properties["token"] = token;
66+
67+
// https://developer.mixpanel.com/reference/track-event
68+
final body = "[${event.toJson()}]";
69+
final url = Uri.https(_url, _path);
70+
if (_debug) print("[ObjectBox] Analysis sending to $url: $body");
71+
return http.post(url,
72+
headers: {'Accept': 'text/plain', 'Content-Type': 'application/json'},
73+
body: body);
74+
}
75+
76+
/// Uses the given values to gather properties and return them as an [Event].
77+
Event buildEvent(String eventName, String distinctId, Pubspec? pubspec) {
78+
final properties = <String, String>{};
79+
properties["distinct_id"] = distinctId;
80+
81+
properties["Tool"] = "Dart Generator";
82+
// This is (in most cases) not the actually used version,
83+
// but the version range allowed.
84+
final obxDep = pubspec?.dependencies["objectbox"];
85+
if (obxDep != null && obxDep is HostedDependency) {
86+
properties["Version"] = obxDep.version.toString();
87+
}
88+
89+
final dartVersion = RegExp('([0-9]+).([0-9]+).([0-9]+)')
90+
.firstMatch(Platform.version)
91+
?.group(0);
92+
properties["Dart"] = dartVersion ?? "unknown";
93+
// true or false is enough as Dart version above is tied closely to a
94+
// specific Flutter release (see https://docs.flutter.dev/development/tools/sdk/releases).
95+
final hasFlutter = pubspec?.dependencies["flutter"] != null;
96+
properties["Flutter"] = hasFlutter.toString();
97+
98+
properties["BuildOS"] = Platform.operatingSystem;
99+
properties["BuildOSVersion"] = Platform.operatingSystemVersion;
100+
101+
// Note: If no CI detected, do not set CI property.
102+
final ci = Platform.environment["CI"];
103+
if (ci != null) {
104+
properties["CI"] = ci;
105+
}
106+
107+
// If ISO code (xx-XX or xx_XX format), split into lang and region.
108+
// Otherwise set to unknown.
109+
final locale = Platform.localeName;
110+
var splitLocale =
111+
locale.contains("_") ? locale.split("_") : locale.split("-");
112+
properties["lang"] = splitLocale.isNotEmpty ? splitLocale[0] : "unknown";
113+
properties["c"] = splitLocale.length >= 2 ? splitLocale[1] : "unknown";
114+
115+
return Event(eventName, properties);
116+
}
117+
118+
Future<String?> _getToken() async {
119+
final uri = Uri.parse("package:objectbox_generator/$tokenFilePath");
120+
final resolvedUri = await Isolate.resolvePackageUri(uri);
121+
if (resolvedUri != null) {
122+
final file = File.fromUri(resolvedUri);
123+
try {
124+
if (await file.exists()) {
125+
final lines = await file.readAsLines();
126+
if (lines.length >= 2) {
127+
return decryptToken(lines[0], lines[1]);
128+
}
129+
}
130+
} catch (e) {
131+
// Ignore.
132+
}
133+
}
134+
return null;
135+
}
136+
137+
/// Takes a Base64 encoded secret key and secret text (which is a [SecretBox]
138+
/// concatenation) and returns the decrypted text.
139+
Future<String> decryptToken(
140+
String secretKeyBase64, String secretTextBase64) async {
141+
final algorithm = Chacha20.poly1305Aead();
142+
var secretKeyBytes = base64Decode(secretKeyBase64);
143+
final secretKey = SecretKeyData(secretKeyBytes);
144+
145+
final secretBox = SecretBox.fromConcatenation(
146+
base64Decode(secretTextBase64),
147+
nonceLength: algorithm.nonceLength,
148+
macLength: algorithm.macAlgorithm.macLength);
149+
150+
final clearText = await algorithm.decrypt(secretBox, secretKey: secretKey);
151+
152+
return utf8.decode(clearText);
153+
}
154+
}
155+
156+
/// Wrapper for data to be sent for analysis. Use [toJson] to return a
157+
/// JSON object representation.
158+
class Event {
159+
final String name;
160+
final Map<String, String> properties;
161+
162+
/// See class documentation.
163+
Event(this.name, this.properties);
164+
165+
/// Return this as a JSON object.
166+
String toJson() {
167+
final map = {'event': name, 'properties': properties};
168+
return jsonEncode(map);
169+
}
170+
171+
@override
172+
String toString() => toJson();
173+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
import 'dart:math';
4+
import 'dart:typed_data';
5+
6+
/// Stores properties to be used by the analysis tool.
7+
class BuildProperties {
8+
static const _fileName = ".objectbox-dart-build";
9+
static const _keyUid = "uid";
10+
static const _keyLastSentMs = "lastSent";
11+
12+
/// An identifier created from random data.
13+
final String uid;
14+
15+
/// The last time an event was sent in milliseconds since epoch.
16+
final int lastSentMs;
17+
18+
BuildProperties._(this.uid, this.lastSentMs);
19+
20+
/// Creates a new UID and last sent time of now.
21+
BuildProperties.create()
22+
: uid = _generateUid(),
23+
lastSentMs = DateTime.now().millisecondsSinceEpoch;
24+
25+
/// Uses the existing UID and last sent time of now.
26+
BuildProperties.updateLastSentMs(BuildProperties buildProperties)
27+
: uid = buildProperties.uid,
28+
lastSentMs = DateTime.now().millisecondsSinceEpoch;
29+
30+
/// Returns values read from an existing file. If the file can not be read or
31+
/// it does not have any of the expected properties, returns null.
32+
///
33+
/// By default uses a file in the users home directory using the default
34+
/// file name. Supply [filePath] to use that instead.
35+
static Future<BuildProperties?> get({String? filePath}) async {
36+
final file = _buildFile(filePath);
37+
if (file == null) return null;
38+
39+
dynamic buildPropertiesUnsafe;
40+
try {
41+
var json = await file.readAsString();
42+
buildPropertiesUnsafe = jsonDecode(json);
43+
} catch (e) {
44+
buildPropertiesUnsafe = null;
45+
}
46+
47+
final Map<String, dynamic> buildProperties;
48+
if (buildPropertiesUnsafe is Map<String, dynamic>) {
49+
buildProperties = buildPropertiesUnsafe;
50+
} else {
51+
return null;
52+
}
53+
54+
final uidOrNull = buildProperties[_keyUid];
55+
final String uid;
56+
if (uidOrNull != null && uidOrNull is String && uidOrNull.isNotEmpty) {
57+
uid = uidOrNull;
58+
} else {
59+
return null;
60+
}
61+
62+
final lastSentMsOrNull = buildProperties[_keyLastSentMs];
63+
final int lastSentMs;
64+
if (lastSentMsOrNull != null &&
65+
lastSentMsOrNull is int &&
66+
lastSentMsOrNull > 0) {
67+
lastSentMs = lastSentMsOrNull;
68+
} else {
69+
return null;
70+
}
71+
72+
return BuildProperties._(uid, lastSentMs);
73+
}
74+
75+
/// Writes the current values to a file. Returns if it was successful.
76+
///
77+
/// By default uses a file in the [getOutDirectoryPath] using the default
78+
/// [_fileName]. Supply [filePath] to use that instead.
79+
Future<bool> write({String? filePath}) async {
80+
final file = _buildFile(filePath);
81+
if (file == null) {
82+
return false;
83+
}
84+
try {
85+
await file.parent.create(recursive: true);
86+
await file.writeAsString(
87+
jsonEncode({_keyUid: uid, _keyLastSentMs: lastSentMs}));
88+
return true;
89+
} catch (e) {
90+
return false;
91+
}
92+
}
93+
94+
/// Creates a file using the default [_fileName] in the [getOutDirectoryPath].
95+
/// Supply [filePath] to use that instead.
96+
static File? _buildFile(String? filePath) {
97+
if (filePath == null) {
98+
final outDir = getOutDirectoryPath();
99+
if (outDir == null) {
100+
return null;
101+
}
102+
filePath = "$outDir/$_fileName";
103+
}
104+
return File(filePath);
105+
}
106+
107+
/// Gets a directory path to store the file output of this.
108+
/// Or null on not supported platforms.
109+
///
110+
/// Returns the user home directory on Linux and a local app data (not
111+
/// roaming, machine specific) directory on Windows.
112+
static String? getOutDirectoryPath() {
113+
final env = Platform.environment;
114+
if (Platform.isLinux || Platform.isMacOS) {
115+
return env["HOME"];
116+
} else if (Platform.isWindows) {
117+
final localAppDataPath = env["LOCALAPPDATA"];
118+
if (localAppDataPath != null) {
119+
return "$localAppDataPath/objectbox-dart";
120+
}
121+
}
122+
return null;
123+
}
124+
125+
/// Generates a randomly generated 64-bit integer and returns it encoded as a
126+
/// base64 string with padding characters removed.
127+
static String _generateUid() {
128+
// nextInt only supports values up to 1<<32,
129+
// so concatenate two to get a 64-bit integer.
130+
final random = Random.secure();
131+
final rightPart = random.nextInt(1 << 32);
132+
final leftPart = random.nextInt(1 << 32);
133+
final uid = (leftPart << 32) | rightPart;
134+
135+
// Convert to a base64 encoded string.
136+
final uidBytes = Uint8List(8)..buffer.asInt64List()[0] = uid;
137+
var uidEncoded = base64Encode(uidBytes);
138+
139+
// Remove the padding as the value is never decoded.
140+
while (uidEncoded.endsWith("=")) {
141+
uidEncoded = uidEncoded.substring(0, uidEncoded.length - 1);
142+
}
143+
return uidEncoded;
144+
}
145+
}

generator/lib/src/code_builder.dart

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'dart:convert';
44

55
import 'package:build/build.dart';
66
import 'package:glob/glob.dart';
7+
import 'package:objectbox_generator/src/analysis/analysis.dart';
78
import 'package:objectbox_generator/src/builder_dirs.dart';
89
import 'package:path/path.dart' as path;
910
import 'package:objectbox/internal.dart';
@@ -67,6 +68,8 @@ class CodeBuilder extends Builder {
6768
// generate binding code
6869
updateCode(model, files.keys.toList(growable: false), buildStep,
6970
builderDirs, pubspec);
71+
72+
await ObjectBoxAnalysis().sendBuildEvent(pubspec);
7073
}
7174

7275
Future<ModelInfo> updateModel(List<ModelEntity> entities, BuildStep buildStep,

generator/pubspec.yaml

+3
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@ dependencies:
1818
source_gen: ^1.0.0
1919
pubspec_parse: ^1.0.0
2020
yaml: ^3.0.0
21+
http: ^0.13.5
22+
cryptography: ^2.0.5
2123

2224
dev_dependencies:
2325
test: ^1.16.5
2426
lints: ^2.0.1
2527
crypto: ^3.0.2
28+
pub_semver: ^2.1.3
2629

2730
# NOTE: remove before publishing
2831
dependency_overrides:

generator/test/.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Created by analysis_test.dart
2+
analysis_test_uid_new.json

0 commit comments

Comments
 (0)