Skip to content

Commit 82a8694

Browse files
authored
Merge pull request #365 from Countly/record_metrics
Record metrics
2 parents 26f1ef1 + 9bfb3dd commit 82a8694

File tree

5 files changed

+139
-0
lines changed

5 files changed

+139
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
## XX.XX.XX
22
* Added a new function "addCustomNetworkRequestHeaders: customHeaderValues" for providing or overriding custom headers after init.
33
* Default request method is now set to "POST"
4+
* Added a new function "recordMetrics: metricsOverride" to send a device metrics request.
5+
* Added a new Consent option "metrics" for controlling "recordMetrics" method. (This has no effect on Session metrics.)
46

57
## 25.4.1
68
* Added fullscreen support for feedback widgets.

android/src/main/java/ly/count/dart/countly_flutter/CountlyFlutterPlugin.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,10 @@ else if ("attemptToSendStoredRequests".equals(call.method)) {
341341
Map<String, String> customHeaderValues = toMapString(args.getJSONObject(0));
342342
Countly.sharedInstance().requestQueue().addCustomNetworkRequestHeaders(customHeaderValues);
343343
result.success("addCustomNetworkRequestHeaders success!");
344+
} else if ("recordMetrics".equals(call.method)) {
345+
Map<String, String> metricsOverride = toMapString(args.getJSONObject(0));
346+
Countly.sharedInstance().requestQueue().recordMetrics(metricsOverride);
347+
result.success("recordMetrics success!");
344348
} else if ("setHttpPostForced".equals(call.method)) {
345349
boolean isEnabled = args.getBoolean(0);
346350
this.config.setHttpPostForced(isEnabled);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:countly_flutter/countly_flutter.dart';
5+
import 'package:flutter/foundation.dart';
6+
import 'package:flutter_test/flutter_test.dart';
7+
import 'package:integration_test/integration_test.dart';
8+
9+
import '../utils.dart';
10+
import '../views_tests/auto_view_flow2_test.dart';
11+
12+
/// Tests for recording custom metrics using the Countly SDK.
13+
/// Verifies that metrics are correctly sent in the network request.
14+
/// Covers scenarios with normal key-value pairs and edge cases like empty keys/values.
15+
void main() {
16+
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
17+
testWidgets('recordMetrics_test', (WidgetTester tester) async {
18+
CountlyConfig config = CountlyConfig(SERVER_URL, APP_KEY).setLoggingEnabled(true);
19+
await Countly.initWithConfig(config);
20+
await Future.delayed(const Duration(seconds: 2));
21+
22+
// Add custom headers
23+
await Countly.instance.recordMetrics({'metric1': '100', 'metric2': '200', '_device': 'custom_device', '': 'empty_key', 'empty_value': ''});
24+
25+
var requestQueue = await getRequestQueue();
26+
expect(requestQueue.length, 2);
27+
validateBeginSessionRequest(requestQueue[0]);
28+
29+
Map<String, List<String>> queryParams = Uri.parse('?${requestQueue[1]}').queryParametersAll;
30+
var rqMetrics = jsonDecode(queryParams['metrics']![0]);
31+
Map<String, dynamic> customMetrics = {
32+
'metric1': '100',
33+
'metric2': '200',
34+
'_device': 'custom_device',
35+
'': 'empty_key',
36+
'empty_value': '',
37+
};
38+
39+
if(Platform.isAndroid) {
40+
// Android SDK ignores empty key metrics
41+
customMetrics.remove('');
42+
}
43+
44+
validateMetrics(rqMetrics, customMetrics);
45+
});
46+
}
47+
48+
const _androidMetricKeys = {
49+
'_os',
50+
'_os_version',
51+
'_app_version',
52+
'_device',
53+
'_device_type',
54+
'_resolution',
55+
'_density',
56+
'_locale',
57+
'_manufacturer',
58+
'_carrier',
59+
'_has_hinge',
60+
};
61+
62+
const _iosMetricKeys = {'_os', '_os_version', '_app_version', '_device', '_device_type', '_resolution', '_density', '_locale'};
63+
64+
const _webMetricKeys = {
65+
'_os',
66+
'_os_version',
67+
'_app_version',
68+
'_device',
69+
'_locale',
70+
'_browser',
71+
'_browser_version',
72+
'_ua',
73+
};
74+
75+
void validateMetrics(Map<String, dynamic> metrics, Map<String, dynamic>? customMetrics) {
76+
late Set<String> expectedKeys;
77+
78+
if (kIsWeb) {
79+
expectedKeys = _webMetricKeys;
80+
} else if (Platform.isAndroid) {
81+
expectedKeys = _androidMetricKeys;
82+
} else if (Platform.isIOS) {
83+
expectedKeys = _iosMetricKeys;
84+
} else {
85+
throw UnsupportedError('Unknown platform in metric validation');
86+
}
87+
88+
expectedKeys = expectedKeys.union(customMetrics?.keys.toSet() ?? {});
89+
90+
expect(metrics.length, equals(expectedKeys.length), reason: 'Metric key count mismatch');
91+
92+
for (final key in expectedKeys) {
93+
if (customMetrics != null && customMetrics.containsKey(key)) {
94+
expect(metrics[key], customMetrics[key]);
95+
} else {
96+
expect(
97+
metrics.containsKey(key),
98+
true,
99+
reason: 'Missing metric key: $key',
100+
);
101+
}
102+
}
103+
104+
for (final entry in (customMetrics ?? {}).entries) {
105+
expect(
106+
metrics[entry.key],
107+
entry.value,
108+
reason: 'Custom metric key-value mismatch for key: ${entry.key}',
109+
);
110+
}
111+
}

ios/Classes/CountlyFlutterPlugin.m

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,12 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result
13961396
[Countly.sharedInstance addCustomNetworkRequestHeaders:customHeaderValues];
13971397
result(nil);
13981398
});
1399+
} else if ([@"recordMetrics" isEqualToString:call.method]) {
1400+
dispatch_async(dispatch_get_main_queue(), ^{
1401+
NSDictionary* metricsOverride = [command objectAtIndex:0];
1402+
[Countly.sharedInstance recordMetrics:metricsOverride];
1403+
result(nil);
1404+
});
13991405
} else {
14001406
result(FlutterMethodNotImplemented);
14011407
}

lib/src/countly_flutter.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ abstract class CountlyConsent {
5757
static const String feedback = 'feedback';
5858
static const String remoteConfig = 'remote-config';
5959
static const String content = 'content';
60+
static const String metrics = 'metrics';
6061
}
6162

6263
class Countly {
@@ -1711,6 +1712,21 @@ class Countly {
17111712
await _channel.invokeMethod('addCustomNetworkRequestHeaders', <String, dynamic>{'data': json.encode(args)});
17121713
}
17131714

1715+
1716+
/// Record device metrics manually as a standalone call
1717+
/// [Map<String, String> metricsOverride] - map of key value pairs to override the default metrics
1718+
Future<void> recordMetrics(Map<String, String> metricsOverride) async {
1719+
if (!_instance._countlyState.isInitialized) {
1720+
log('recordMetrics, "initWithConfig" must be called before "recordMetrics"', logLevel: LogLevel.ERROR);
1721+
return;
1722+
}
1723+
1724+
List<dynamic> args = [];
1725+
args.add(metricsOverride);
1726+
1727+
await _channel.invokeMethod('recordMetrics', <String, dynamic>{'data': json.encode(args)});
1728+
}
1729+
17141730
/// starts a timed event
17151731
/// returns error or success message
17161732
@Deprecated('This function is deprecated, please use "startEvent" of events instead')

0 commit comments

Comments
 (0)