Skip to content

Commit 8e56924

Browse files
author
serverpod_cloud
committed
feat(scloud): 683ab67eebdabda71d3ae6add51c7553181e8fcc
1 parent 76b5249 commit 8e56924

13 files changed

Lines changed: 272 additions & 15 deletions

serverpod_cloud_cli/bin/serverpod_cloud_cli.dart

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ Future<void> _main(final List<String> args, final CommandLogger logger) async {
4848
final runner = CloudCliCommandRunner.create(
4949
logger: logger,
5050
version: cliVersion,
51-
onAnalyticsEvent: _reportAnalyticsEvent,
51+
onAnalyticsEvent: (final event, final properties) =>
52+
_reportAnalyticsEvent(event, properties, logger),
5253
);
5354
try {
5455
await runner.run(args);
@@ -80,9 +81,14 @@ ${error.runtimeType} $error''';
8081
void _reportAnalyticsEvent(
8182
final String event,
8283
final Map<String, dynamic> properties,
84+
final CommandLogger logger,
8385
) {
84-
_postHogAnalytics.track(event: event, properties: properties);
85-
_analytics.track(event: event, properties: properties);
86+
try {
87+
_postHogAnalytics.track(event: event, properties: properties);
88+
_analytics.track(event: event, properties: properties);
89+
} catch (e, stackTrace) {
90+
logger.debug('Analytics event failed: $e\n$stackTrace');
91+
}
8692
}
8793

8894
const _postHogApiKey = 'phc_xGBPHgcrTrDuWGtyNX3UJODXgnR684rzRPZjWRlqVxf';

serverpod_cloud_cli/lib/command_runner/cloud_cli_command.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ See the full documentation at: https://docs.serverpod.cloud/references/cli/comma
7070
await AuthLoginCommands.login(
7171
logger: logger,
7272
globalConfig: globalConfiguration,
73+
cloudApiClient: client,
7374
persistent: true,
7475
openBrowser: globalConfiguration.browser,
7576
);

serverpod_cloud_cli/lib/command_runner/cloud_cli_command_runner.dart

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,21 @@ class CloudCliCommandRunner extends BetterCommandRunner<GlobalOption, void> {
210210
}
211211
}
212212

213+
@override
214+
void sendAnalyticsEvent(
215+
final String event, [
216+
final Map<String, dynamic> properties = const {},
217+
]) {
218+
final enrichedProperties = Map<String, dynamic>.from(properties);
219+
final cloudUser = ResourceManager.tryFetchServerpodCloudUserDataSync(
220+
localStoragePath: globalConfiguration.scloudDir.path,
221+
);
222+
if (cloudUser != null) {
223+
enrichedProperties['cloud_user_id'] = cloudUser.id;
224+
}
225+
super.sendAnalyticsEvent(event, enrichedProperties);
226+
}
227+
213228
@override
214229
Future<bool> determineAnalyticsSettings() async {
215230
if (onAnalyticsEvent == null) {
@@ -238,7 +253,7 @@ class CloudCliCommandRunner extends BetterCommandRunner<GlobalOption, void> {
238253
}
239254

240255
final confirm = await logger.confirm(
241-
'Do you agree to sending anonymous command usage analytics to Serverpod?',
256+
'Do you agree to sending command usage analytics to Serverpod?',
242257
defaultValue: true,
243258
);
244259
await settings.setEnableAnalytics(confirm);

serverpod_cloud_cli/lib/command_runner/commands/auth_command.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class CloudLoginCommand extends CloudCliCommand<LoginCommandOption> {
113113
await AuthLoginCommands.login(
114114
logger: logger,
115115
globalConfig: globalConfiguration,
116+
cloudApiClient: runner.serviceProvider.cloudApiClient,
116117
timeLimit: timeLimit,
117118
persistent: persistent,
118119
openBrowser: openBrowser,

serverpod_cloud_cli/lib/commands/auth/auth_login.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import 'dart:async';
22

3+
import 'package:ground_control_client/ground_control_client.dart';
34
import 'package:serverpod_cloud_cli/command_logger/command_logger.dart';
45
import 'package:serverpod_cloud_cli/command_runner/cloud_cli_command_runner.dart';
56
import 'package:serverpod_cloud_cli/persistent_storage/models/serverpod_cloud_auth_data.dart';
7+
import 'package:serverpod_cloud_cli/persistent_storage/models/serverpod_cloud_user_data.dart';
68
import 'package:serverpod_cloud_cli/persistent_storage/resource_manager.dart';
79
import 'package:serverpod_cloud_cli/shared/exceptions/exit_exceptions.dart';
810
import 'package:serverpod_cloud_cli/util/browser_launcher.dart';
@@ -12,6 +14,7 @@ abstract class AuthLoginCommands {
1214
static Future<void> login({
1315
required final CommandLogger logger,
1416
required final GlobalConfiguration globalConfig,
17+
required final Client cloudApiClient,
1518
final Duration timeLimit = const Duration(seconds: 300),
1619
required final bool persistent,
1720
required final bool openBrowser,
@@ -67,8 +70,30 @@ abstract class AuthLoginCommands {
6770
authData: ServerpodCloudAuthData(token),
6871
localStoragePath: localStoragePath.path,
6972
);
73+
await fetchAndStoreServerpodCloudUserData(
74+
cloudApiClient: cloudApiClient,
75+
localStoragePath: localStoragePath.path,
76+
logger: logger,
77+
);
7078
}
7179

7280
logger.success('Successfully logged in to Serverpod cloud.');
7381
}
82+
83+
static Future<void> fetchAndStoreServerpodCloudUserData({
84+
required final Client cloudApiClient,
85+
required final String localStoragePath,
86+
required final CommandLogger logger,
87+
}) async {
88+
try {
89+
final user = await cloudApiClient.users.readUser();
90+
final cloudUserId = user.id.toString();
91+
await ResourceManager.storeServerpodCloudUserData(
92+
cloudUserData: ServerpodCloudUserData(cloudUserId),
93+
localStoragePath: localStoragePath,
94+
);
95+
} on Exception catch (e) {
96+
logger.debug('Failed to fetch user data: $e');
97+
}
98+
}
7499
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/// Exception thrown when a local data storage operation fails.
2+
class LocalDataStorageException implements Exception {
3+
final String message;
4+
final Object? error;
5+
6+
LocalDataStorageException(this.message, [this.error]);
7+
8+
@override
9+
String toString() =>
10+
'LocalDataStorageException: $message${error != null ? '\nError: $error' : ''}';
11+
}

serverpod_cloud_cli/lib/persistent_storage/models/scloud_settings_data.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ class ServerpodCloudSettingsData {
77
ServerpodCloudSettingsData._(this.enableAnalytics);
88

99
factory ServerpodCloudSettingsData.fromJson(final Map<String, dynamic> json) {
10-
return ServerpodCloudSettingsData._(json['enable_analytics'] as bool?);
10+
return ServerpodCloudSettingsData._(
11+
json['command_usage_analytics'] as bool?,
12+
);
1113
}
1214

13-
Map<String, dynamic> toJson() => {'enable_analytics': enableAnalytics};
15+
Map<String, dynamic> toJson() => {'command_usage_analytics': enableAnalytics};
1416

1517
@override
1618
String toString() => 'ScloudSettings(enableAnalytics: $enableAnalytics)';
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class ServerpodCloudUserData {
2+
late final String id;
3+
4+
ServerpodCloudUserData(this.id);
5+
6+
factory ServerpodCloudUserData.fromJson(final Map<String, dynamic> json) {
7+
return ServerpodCloudUserData(json['id'] as String);
8+
}
9+
10+
Map<String, dynamic> toJson() => {'id': id};
11+
}

serverpod_cloud_cli/lib/persistent_storage/resource_manager.dart

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import 'dart:convert';
12
import 'dart:io';
23

34
import 'package:cli_tools/cli_tools.dart';
45
import 'package:path/path.dart' as p;
56
import 'package:serverpod_cloud_cli/command_logger/command_logger.dart';
67
import 'package:serverpod_cloud_cli/persistent_storage/models/serverpod_cloud_auth_data.dart';
8+
import 'package:serverpod_cloud_cli/persistent_storage/models/serverpod_cloud_user_data.dart';
79
import 'package:serverpod_cloud_cli/shared/exceptions/exit_exceptions.dart';
810
import 'package:uuid/uuid.dart';
911

12+
import 'local_data_storage_exception.dart';
1013
import 'models/scloud_settings_data.dart';
1114

1215
abstract class ResourceManager {
@@ -43,6 +46,9 @@ abstract class ResourceManager {
4346
return userId;
4447
}
4548

49+
/// Removes Serverpod Cloud authentication and user data from local storage.
50+
///
51+
/// Throws [LocalDataStorageException] if the file cannot be deleted.
4652
static Future<void> removeServerpodCloudAuthData({
4753
required final String localStoragePath,
4854
}) async {
@@ -51,13 +57,73 @@ abstract class ResourceManager {
5157
fileName: ResourceManagerConstants.serverpodCloudAuthFilePath,
5258
localStoragePath: localStoragePath,
5359
);
60+
await LocalStorageManager.removeFile(
61+
fileName: ResourceManagerConstants.serverpodCloudUserFilePath,
62+
localStoragePath: localStoragePath,
63+
);
5464
} on DeleteException catch (e) {
55-
throw Exception(
65+
throw LocalDataStorageException(
5666
'Failed to remove serverpod cloud data. error: ${e.error}',
67+
e,
68+
);
69+
}
70+
}
71+
72+
/// Stores Serverpod Cloud user data to local storage.
73+
///
74+
/// Throws [LocalDataStorageException] if the file cannot be created, serialized, or written.
75+
static Future<void> storeServerpodCloudUserData({
76+
required final ServerpodCloudUserData cloudUserData,
77+
required final String localStoragePath,
78+
}) async {
79+
try {
80+
await LocalStorageManager.storeJsonFile(
81+
fileName: ResourceManagerConstants.serverpodCloudUserFilePath,
82+
json: cloudUserData.toJson(),
83+
localStoragePath: localStoragePath,
84+
);
85+
} on CreateException catch (e) {
86+
throw LocalDataStorageException(
87+
'Failed to store serverpod cloud user data. error: ${e.error}',
88+
e,
89+
);
90+
} on SerializationException catch (e) {
91+
throw LocalDataStorageException(
92+
'Failed to store serverpod cloud user data. error: ${e.error}',
93+
e,
5794
);
95+
} on WriteException catch (e) {
96+
throw LocalDataStorageException(
97+
'Failed to store serverpod cloud user data. error: ${e.error}',
98+
e,
99+
);
100+
}
101+
}
102+
103+
static ServerpodCloudUserData? tryFetchServerpodCloudUserDataSync({
104+
required final String localStoragePath,
105+
}) {
106+
try {
107+
final file = File(
108+
p.join(
109+
localStoragePath,
110+
ResourceManagerConstants.serverpodCloudUserFilePath,
111+
),
112+
);
113+
if (!file.existsSync()) {
114+
return null;
115+
}
116+
final json = file.readAsStringSync();
117+
final decoded = jsonDecode(json) as Map<String, dynamic>;
118+
return ServerpodCloudUserData.fromJson(decoded);
119+
} catch (_) {
120+
return null;
58121
}
59122
}
60123

124+
/// Stores Serverpod Cloud authentication data to local storage.
125+
///
126+
/// Throws [LocalDataStorageException] if the file cannot be created, serialized, or written.
61127
static Future<void> storeServerpodCloudAuthData({
62128
required final ServerpodCloudAuthData authData,
63129
required final String localStoragePath,
@@ -69,16 +135,19 @@ abstract class ResourceManager {
69135
localStoragePath: localStoragePath,
70136
);
71137
} on CreateException catch (e) {
72-
throw Exception(
138+
throw LocalDataStorageException(
73139
'Failed to store serverpod cloud data. error: ${e.error}',
140+
e,
74141
);
75142
} on SerializationException catch (e) {
76-
throw Exception(
143+
throw LocalDataStorageException(
77144
'Failed to store serverpod cloud data. error: ${e.error}',
145+
e,
78146
);
79147
} on WriteException catch (e) {
80-
throw Exception(
148+
throw LocalDataStorageException(
81149
'Failed to store serverpod cloud data. error: ${e.error}',
150+
e,
82151
);
83152
}
84153
}
@@ -199,6 +268,9 @@ abstract class ResourceManager {
199268
return null;
200269
}
201270

271+
/// Stores Serverpod Cloud settings data to local storage.
272+
///
273+
/// Throws [FailureException] if the file cannot be written.
202274
static Future<void> storeSettings({
203275
required final ServerpodCloudSettingsData settings,
204276
required final String localStoragePath,
@@ -214,6 +286,9 @@ abstract class ResourceManager {
214286
}
215287
}
216288

289+
/// Loads Serverpod Cloud settings data from local storage.
290+
///
291+
/// Throws [FailureException] if the file cannot be read or deserialized.
217292
static Future<ServerpodCloudSettingsData?> tryLoadSettings({
218293
required final String localStoragePath,
219294
}) async {
@@ -231,6 +306,7 @@ abstract class ResourceManager {
231306

232307
abstract class ResourceManagerConstants {
233308
static const serverpodCloudAuthFilePath = 'serverpod_cloud_auth.json';
309+
static const serverpodCloudUserFilePath = 'serverpod_cloud_user.json';
234310
static const latestVersionFilePath = 'latest_cli_version.json';
235311
static const settingsFilePath = 'settings.json';
236312
}

serverpod_cloud_cli/test_integration/analytics_test.dart

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import 'package:path/path.dart' as p;
22
import 'package:serverpod_cloud_cli/command_runner/cloud_cli_command_runner.dart';
33
import 'package:serverpod_cloud_cli/command_runner/helpers/cloud_cli_service_provider.dart';
4+
import 'package:serverpod_cloud_cli/persistent_storage/models/serverpod_cloud_user_data.dart';
5+
import 'package:serverpod_cloud_cli/persistent_storage/resource_manager.dart';
46
import 'package:ground_control_client/ground_control_client_test_tools.dart';
57
import 'package:test/test.dart';
68
import 'package:test_descriptor/test_descriptor.dart' as d;
@@ -12,12 +14,14 @@ void main() {
1214
final logger = TestCommandLogger(printToStdout: false);
1315

1416
final List<String> analyticsEvents = [];
17+
final List<Map<String, dynamic>> analyticsProperties = [];
1518

1619
final client = ClientMock();
1720

1821
setUp(() async {
1922
logger.clear();
2023
analyticsEvents.clear();
24+
analyticsProperties.clear();
2125
});
2226

2327
group('Given default non-prod-env suppression (enabled)', () {
@@ -35,6 +39,7 @@ void main() {
3539
),
3640
onAnalyticsEvent: (final event, final properties) {
3741
analyticsEvents.add(event);
42+
analyticsProperties.add(Map<String, dynamic>.from(properties));
3843
},
3944
);
4045
});
@@ -99,6 +104,7 @@ void main() {
99104
),
100105
onAnalyticsEvent: (final event, final properties) {
101106
analyticsEvents.add(event);
107+
analyticsProperties.add(Map<String, dynamic>.from(properties));
102108
},
103109
enableAnalyticsForAllEnvs: true,
104110
);
@@ -115,7 +121,7 @@ void main() {
115121
logger.confirmCalls.single,
116122
equalsConfirmCall(
117123
message:
118-
'Do you agree to sending anonymous command usage analytics to Serverpod?',
124+
'Do you agree to sending command usage analytics to Serverpod?',
119125
defaultValue: true,
120126
),
121127
);
@@ -274,5 +280,29 @@ void main() {
274280
});
275281
});
276282
});
283+
284+
group('and cloud user data stored', () {
285+
const cloudUserId = 'test-cloud-user-uuid';
286+
287+
setUp(() async {
288+
await ResourceManager.storeServerpodCloudUserData(
289+
cloudUserData: ServerpodCloudUserData(cloudUserId),
290+
localStoragePath: settingsDir,
291+
);
292+
});
293+
294+
test('when invoking command with analytics'
295+
' then analytics properties include cloud_user_id', () async {
296+
logger.answerNextConfirmWith(true);
297+
await cli.run(['--config-dir', settingsDir, 'version']);
298+
299+
expect(analyticsEvents, equals(['version']));
300+
expect(analyticsProperties, hasLength(1));
301+
expect(
302+
analyticsProperties.single['cloud_user_id'],
303+
equals(cloudUserId),
304+
);
305+
});
306+
});
277307
});
278308
}

0 commit comments

Comments
 (0)