diff --git a/sdk_compliance_adapter/lib/adapter_server.dart b/sdk_compliance_adapter/lib/adapter_server.dart index d85dda03..f4f3d74e 100644 --- a/sdk_compliance_adapter/lib/adapter_server.dart +++ b/sdk_compliance_adapter/lib/adapter_server.dart @@ -507,6 +507,7 @@ class _CompliancePlatform extends PosthogFlutterPlatformInterface { final client = HttpClient(); Object? value = false; bool? hasExperiment; + var minimalFlagCalledEvents = false; try { final uri = Uri.parse(_host!).resolve('/flags/?v=2'); for (var attempt = 0; attempt <= _maxRetries; attempt++) { @@ -537,6 +538,7 @@ class _CompliancePlatform extends PosthogFlutterPlatformInterface { value = flags[key]; } hasExperiment = _flagHasExperiment(decoded['flags'], key); + minimalFlagCalledEvents = decoded['minimalFlagCalledEvents'] == true; } break; } @@ -548,17 +550,13 @@ class _CompliancePlatform extends PosthogFlutterPlatformInterface { 'uuid': _uuidV4(), 'event': r'$feature_flag_called', 'distinct_id': distinctId, - 'properties': { - 'distinct_id': distinctId, - 'token': _apiKey, - r'$lib': postHogFlutterSdkName, - r'$lib_version': postHogFlutterVersion, - r'$feature_flag': key, - r'$feature_flag_response': value, - if (hasExperiment != null) - r'$feature_flag_has_experiment': hasExperiment, - r'$feature/' + key: value, - }, + 'properties': _featureFlagCalledProperties( + key: key, + value: value, + distinctId: distinctId, + hasExperiment: hasExperiment, + minimalFlagCalledEvents: minimalFlagCalledEvents, + ), 'timestamp': DateTime.now().toUtc().toIso8601String(), }); state.totalEventsCaptured++; @@ -567,6 +565,43 @@ class _CompliancePlatform extends PosthogFlutterPlatformInterface { return value; } + /// Builds the `$feature_flag_called` properties. + /// + /// When the `/flags?v=2` response carries top-level + /// `minimalFlagCalledEvents == true` and the flag is not linked to an + /// experiment (`has_experiment == false`), only the cross-SDK minimal + /// allowlist is sent. Any missing signal (either field absent, a legacy + /// response shape, or `has_experiment` unreported) falls back to the full + /// legacy shape. `distinct_id` and `token` are transport fields this + /// adapter carries in every event's properties and stay in both shapes. + Map _featureFlagCalledProperties({ + required String key, + required Object? value, + required String distinctId, + required bool? hasExperiment, + required bool minimalFlagCalledEvents, + }) { + final allowlisted = { + 'distinct_id': distinctId, + 'token': _apiKey, + r'$lib': postHogFlutterSdkName, + r'$lib_version': postHogFlutterVersion, + r'$feature_flag': key, + r'$feature_flag_response': value, + }; + if (minimalFlagCalledEvents && hasExperiment == false) { + return { + ...allowlisted, + r'$feature_flag_has_experiment': false, + }; + } + return { + ...allowlisted, + if (hasExperiment != null) r'$feature_flag_has_experiment': hasExperiment, + r'$feature/' + key: value, + }; + } + /// Reads `metadata.has_experiment` from the `/flags?v=2` entry for [key]. /// /// Returns `null` when the server does not report it (e.g. legacy diff --git a/sdk_compliance_adapter/test/minimal_flag_called_events_test.dart b/sdk_compliance_adapter/test/minimal_flag_called_events_test.dart new file mode 100644 index 00000000..5c715c0c --- /dev/null +++ b/sdk_compliance_adapter/test/minimal_flag_called_events_test.dart @@ -0,0 +1,270 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter_sdk_compliance_adapter/adapter_server.dart'; + +void main() { + test( + r'gated flag without experiment sends exactly the minimal allowlist', + () async { + final mockServer = await _MockPostHogServer.start({ + 'flags': { + 'plain-flag': { + 'key': 'plain-flag', + 'enabled': true, + 'metadata': {'has_experiment': false}, + }, + }, + 'featureFlags': {'plain-flag': true}, + 'minimalFlagCalledEvents': true, + }); + addTearDown(mockServer.close); + + final harness = await _AdapterHarness.start(mockServer); + addTearDown(harness.close); + + final properties = await harness.captureFeatureFlagCalled('plain-flag'); + + expect( + properties.keys, + unorderedEquals([ + 'distinct_id', + 'token', + r'$lib', + r'$lib_version', + r'$feature_flag', + r'$feature_flag_response', + r'$feature_flag_has_experiment', + ]), + ); + expect(properties['distinct_id'], 'test-user'); + expect(properties['token'], 'test-api-key'); + expect(properties[r'$feature_flag'], 'plain-flag'); + expect(properties[r'$feature_flag_response'], isTrue); + expect(properties[r'$feature_flag_has_experiment'], isFalse); + }, + ); + + final fullEventCases = [ + ( + description: 'gated flag with an experiment sends the full event', + response: { + 'flags': { + 'experiment-flag': { + 'key': 'experiment-flag', + 'enabled': true, + 'variant': 'control', + 'metadata': {'has_experiment': true}, + }, + }, + 'featureFlags': {'experiment-flag': 'control'}, + 'minimalFlagCalledEvents': true, + }, + key: 'experiment-flag', + expectedHasExperiment: isTrue, + ), + ( + description: 'ungated flag without experiment sends the full event', + response: { + 'flags': { + 'plain-flag': { + 'key': 'plain-flag', + 'enabled': true, + 'metadata': {'has_experiment': false}, + }, + }, + 'featureFlags': {'plain-flag': true}, + }, + key: 'plain-flag', + expectedHasExperiment: isFalse, + ), + ( + description: 'gated flag with unreported has_experiment sends the ' + 'full event', + response: { + 'featureFlags': {'plain-flag': true}, + 'minimalFlagCalledEvents': true, + }, + key: 'plain-flag', + expectedHasExperiment: isNull, + ), + ( + description: 'gated flag with malformed has_experiment metadata sends ' + 'the full event', + response: { + 'flags': { + 'plain-flag': { + 'key': 'plain-flag', + 'enabled': true, + 'metadata': {'has_experiment': 'not-a-bool'}, + }, + }, + 'featureFlags': {'plain-flag': true}, + 'minimalFlagCalledEvents': true, + }, + key: 'plain-flag', + expectedHasExperiment: isNull, + ), + ]; + + for (final testCase in fullEventCases) { + test(testCase.description, () async { + final mockServer = await _MockPostHogServer.start(testCase.response); + addTearDown(mockServer.close); + + final harness = await _AdapterHarness.start(mockServer); + addTearDown(harness.close); + + final properties = await harness.captureFeatureFlagCalled(testCase.key); + + expect(properties[r'$feature_flag'], testCase.key); + expect(properties, contains(r'$feature/' + testCase.key)); + expect( + properties[r'$feature_flag_has_experiment'], + testCase.expectedHasExperiment, + ); + }); + } + + test('the gate tracks the latest flags response', () async { + final mockServer = await _MockPostHogServer.start({ + 'flags': { + 'plain-flag': { + 'key': 'plain-flag', + 'enabled': true, + 'metadata': {'has_experiment': false}, + }, + }, + 'featureFlags': {'plain-flag': true}, + 'minimalFlagCalledEvents': true, + }); + addTearDown(mockServer.close); + + final harness = await _AdapterHarness.start(mockServer); + addTearDown(harness.close); + + final gated = await harness.captureFeatureFlagCalled('plain-flag'); + expect(gated, isNot(contains(r'$feature/plain-flag'))); + + mockServer.flagsResponse = { + 'featureFlags': {'plain-flag': true}, + }; + + final ungated = await harness.captureFeatureFlagCalled('plain-flag'); + expect(ungated, contains(r'$feature/plain-flag')); + }); +} + +/// Runs a [ComplianceAdapter] against a [_MockPostHogServer]. +class _AdapterHarness { + _AdapterHarness._(this._adapter, this._baseUrl, this._mockServer); + + final ComplianceAdapter _adapter; + final String _baseUrl; + final _MockPostHogServer _mockServer; + + static Future<_AdapterHarness> start(_MockPostHogServer mockServer) async { + final adapter = ComplianceAdapter(); + final adapterServer = await adapter.start(port: 0); + final baseUrl = 'http://127.0.0.1:${adapterServer.port}'; + await _postJson('$baseUrl/init', { + 'api_key': 'test-api-key', + 'host': mockServer.url, + 'flush_interval_ms': 60000, + }); + return _AdapterHarness._(adapter, baseUrl, mockServer); + } + + Future close() => _adapter.close(); + + /// Evaluates [key], flushes, and returns the properties of the + /// `$feature_flag_called` event it produced. + Future> captureFeatureFlagCalled(String key) async { + final alreadyBatched = _mockServer.batchedEvents.length; + final response = await _postJson('$_baseUrl/get_feature_flag', { + 'key': key, + 'distinct_id': 'test-user', + }); + expect(response['success'], isTrue); + + await _postJson('$_baseUrl/flush', {}); + + expect(_mockServer.batchedEvents, hasLength(alreadyBatched + 1)); + final event = _mockServer.batchedEvents.last; + expect(event['event'], r'$feature_flag_called'); + return Map.from(event['properties'] as Map); + } +} + +Future> _postJson( + String url, + Map payload, +) async { + final client = HttpClient(); + try { + final request = await client.postUrl(Uri.parse(url)); + request.headers.contentType = ContentType.json; + request.write(jsonEncode(payload)); + final response = await request.close(); + final body = await utf8.decoder.bind(response).join(); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw HttpException('HTTP ${response.statusCode}: $body'); + } + return Map.from(jsonDecode(body) as Map); + } finally { + client.close(force: true); + } +} + +/// Serves `/flags/?v=2` with a swappable [flagsResponse] and records events +/// posted to `/batch`. +class _MockPostHogServer { + _MockPostHogServer._(this._server, this.flagsResponse); + + final HttpServer _server; + Map flagsResponse; + final List> batchedEvents = []; + + String get url => 'http://127.0.0.1:${_server.port}'; + + static Future<_MockPostHogServer> start( + Map flagsResponse, + ) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final mockServer = _MockPostHogServer._(server, flagsResponse); + unawaited(mockServer._serve()); + return mockServer; + } + + Future close() => _server.close(force: true); + + Future _serve() async { + await for (final request in _server) { + final body = await utf8.decoder.bind(request).join(); + if (request.method == 'POST' && + request.uri.path == '/flags/' && + request.uri.queryParameters['v'] == '2') { + request.response.headers.contentType = ContentType.json; + request.response.write(jsonEncode(flagsResponse)); + await request.response.close(); + continue; + } + + if (request.method == 'POST' && request.uri.path == '/batch') { + final decoded = jsonDecode(body) as Map; + for (final event in decoded['batch'] as List) { + batchedEvents.add(Map.from(event as Map)); + } + request.response.headers.contentType = ContentType.json; + request.response.write(jsonEncode({'status': 1})); + await request.response.close(); + continue; + } + + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + } + } +}