From 823ec66aac7d38938bb1a9c805e76da9a2c6696a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 19:23:56 -0400 Subject: [PATCH 1/4] fix(desktop): skip phx_ keys, keep response bodies out of logs, count duplicate copies - Posthog.setup skips a projectToken that is a personal API key (phx_), like the native SDKs, so the key never goes on the wire (INV-32). - PostHogFetchHttpError.toString leaves out the response body, which can echo request values (FC-5/FQ-18); the flush error log names the lane. - captureV1Results reads duplicates_dropped and the per-batch log reports the dropped copies (FQ-19). --- .changeset/v6-desktop-rubric-followups.md | 6 ++++++ posthog_flutter/lib/src/core/capture_v1.dart | 15 +++++++++++---- .../lib/src/core/posthog_core_stateless.dart | 18 +++++++++++++----- posthog_flutter/lib/src/posthog.dart | 8 ++++++++ posthog_flutter/test/core/capture_v1_test.dart | 15 +++++++++++++++ posthog_flutter/test/core/requests_test.dart | 3 ++- posthog_flutter/test/posthog_test.dart | 16 ++++++++++++++++ 7 files changed, 71 insertions(+), 10 deletions(-) create mode 100644 .changeset/v6-desktop-rubric-followups.md diff --git a/.changeset/v6-desktop-rubric-followups.md b/.changeset/v6-desktop-rubric-followups.md new file mode 100644 index 00000000..8c801289 --- /dev/null +++ b/.changeset/v6-desktop-rubric-followups.md @@ -0,0 +1,6 @@ +--- +"posthog_flutter": patch +--- + +- Skip `setup()` with one log line when `projectToken` is a personal API key (`phx_`), so the key is never sent +- On Windows and Linux, stop printing capture's response body in flush error logs, and log how many repeated-uuid copies capture dropped diff --git a/posthog_flutter/lib/src/core/capture_v1.dart b/posthog_flutter/lib/src/core/capture_v1.dart index 491f76f8..e2ba26ff 100644 --- a/posthog_flutter/lib/src/core/capture_v1.dart +++ b/posthog_flutter/lib/src/core/capture_v1.dart @@ -151,12 +151,13 @@ class CaptureV1Identity { } /// The uuids among [uuids] that a capture v1 response [body] asks to -/// retry, with how many of them it rejected (`drop` or `limited`), or null +/// retry, with how many of them it rejected (`drop` or `limited`) and how +/// many repeated copies of them it dropped (`duplicates_dropped`), or null /// when the body has no readable per-event results. /// /// `ok`, `warning`, `drop`, `limited` and any unknown result are final. /// An event missing from the results counts as delivered. -({Set retry, int rejected})? captureV1Results( +({Set retry, int rejected, int duplicates})? captureV1Results( String? body, Iterable uuids, ) { @@ -171,15 +172,21 @@ class CaptureV1Identity { if (results is! Map) return null; final retry = {}; var rejected = 0; + var duplicates = 0; for (final uuid in uuids) { - switch (results[uuid]) { + final result = results[uuid]; + switch (result) { case {'result': 'retry'}: retry.add(uuid); case {'result': 'drop' || 'limited'}: rejected++; } + if (result case {'duplicates_dropped': final int dropped} + when dropped > 0) { + duplicates += dropped; + } } - return (retry: retry, rejected: rejected); + return (retry: retry, rejected: rejected, duplicates: duplicates); } /// Whether two `Set`s hold the same elements. diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 2cd81cb7..9546b16b 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -28,9 +28,11 @@ class PostHogFetchHttpError implements Exception { PostHogFetchHttpError(this.status, this.responseBody, this.reqByteLength, {this.retryAfter}); + /// Leaves out [responseBody]: capture's error details can echo request + /// values. @override String toString() => 'PostHogFetchHttpError: status=$status, ' - 'reqByteLength=$reqByteLength, response=$responseBody'; + 'reqByteLength=$reqByteLength'; } /// Network error during PostHog fetch. @@ -615,7 +617,7 @@ abstract class PostHogCoreStateless { void _flushBackground(_CaptureLane lane) { _flushLane(lane).catchError((e) { - logger.error('Error while flushing PostHog', e); + logger.error('Error while flushing PostHog ${lane.path}', e); }); } @@ -795,9 +797,15 @@ abstract class PostHogCoreStateless { if (results == null) { logger.info('Capture returned ${response.status} without readable ' 'per-event results; ${uuids.length} events count as delivered.'); - } else if (results.rejected > 0) { - logger.warn('Capture dropped ${results.rejected} of ${uuids.length} ' - 'events for good.'); + } else { + if (results.rejected > 0) { + logger.warn('Capture dropped ${results.rejected} of ${uuids.length} ' + 'events for good.'); + } + if (results.duplicates > 0) { + logger.warn('Capture dropped ${results.duplicates} copies of events ' + 'with a repeated uuid.'); + } } final retryUuids = results?.retry ?? const {}; lane.pendingIdentity = diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index cac5f602..e502d51d 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -75,6 +75,14 @@ class Posthog { ); return Future.value(); } + // Capture rejects a personal API key, so it is never sent. + if (config.projectToken.startsWith('phx_')) { + debugPrint( + '[PostHog] projectToken is a personal API key (phx_), not a project ' + 'token. Setup skipped.', + ); + return Future.value(); + } if (!config.enableWindowsAndLinux && !kIsWeb && diff --git a/posthog_flutter/test/core/capture_v1_test.dart b/posthog_flutter/test/core/capture_v1_test.dart index b5340483..822065a3 100644 --- a/posthog_flutter/test/core/capture_v1_test.dart +++ b/posthog_flutter/test/core/capture_v1_test.dart @@ -359,6 +359,21 @@ void main() { }); }); + test('counts the duplicate copies capture dropped', () { + final body = jsonEncode({ + 'results': { + 'a': {'result': 'ok', 'duplicates_dropped': 2}, + 'b': {'result': 'retry', 'duplicates_dropped': 1}, + 'c': {'result': 'ok'}, + }, + }); + + final results = captureV1Results(body, ['a', 'b', 'c'])!; + + expect(results.duplicates, 3); + expect(results.retry, {'b'}); + }); + test('counts a 2xx without readable results as delivered', () async { final server = await LocalPostHogServer.start(); final storage = tempStorage(); diff --git a/posthog_flutter/test/core/requests_test.dart b/posthog_flutter/test/core/requests_test.dart index 004768ce..96ad2262 100644 --- a/posthog_flutter/test/core/requests_test.dart +++ b/posthog_flutter/test/core/requests_test.dart @@ -46,7 +46,8 @@ void main() { client.flush(), throwsA(isA() .having((e) => e.status, 'status', 400) - .having((e) => e.responseBody, 'responseBody', 'invalid batch'))); + .having((e) => e.responseBody, 'responseBody', 'invalid batch') + .having((e) => '$e', 'toString', isNot(contains('invalid'))))); }); test('leave a redirect unfollowed', () async { diff --git a/posthog_flutter/test/posthog_test.dart b/posthog_flutter/test/posthog_test.dart index 0f6d6f66..20438317 100644 --- a/posthog_flutter/test/posthog_test.dart +++ b/posthog_flutter/test/posthog_test.dart @@ -44,6 +44,22 @@ void main() { }, ); + for (final token in [' \n\t ', 'phx_personal_api_key']) { + test('setup is skipped for the project token "$token"', () async { + final lines = []; + final previous = debugPrint; + debugPrint = (message, {wrapWidth}) => lines.add(message); + try { + await Posthog().setup(PostHogConfig(token)); + } finally { + debugPrint = previous; + } + + expect(fakePlatformInterface.receivedConfig, isNull); + expect(lines, hasLength(1)); + }); + } + test('Windows and Linux support is enabled by default', () { expect(PostHogConfig('test_project_token').enableWindowsAndLinux, isTrue); }); From 7e4664dc31cbe2131d4b4c58cf382efb7d97c8de Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 21:06:37 -0400 Subject: [PATCH 2/4] fix(desktop): clamp duplicates_dropped to the batch size --- posthog_flutter/lib/src/core/capture_v1.dart | 9 +++++---- posthog_flutter/test/core/capture_v1_test.dart | 12 ++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/posthog_flutter/lib/src/core/capture_v1.dart b/posthog_flutter/lib/src/core/capture_v1.dart index e2ba26ff..6d26fa63 100644 --- a/posthog_flutter/lib/src/core/capture_v1.dart +++ b/posthog_flutter/lib/src/core/capture_v1.dart @@ -170,10 +170,11 @@ class CaptureV1Identity { return null; } if (results is! Map) return null; + final batch = uuids.toList(growable: false); final retry = {}; var rejected = 0; var duplicates = 0; - for (final uuid in uuids) { + for (final uuid in batch) { final result = results[uuid]; switch (result) { case {'result': 'retry'}: @@ -181,9 +182,9 @@ class CaptureV1Identity { case {'result': 'drop' || 'limited'}: rejected++; } - if (result case {'duplicates_dropped': final int dropped} - when dropped > 0) { - duplicates += dropped; + // Clamped so a malformed count can't exceed the batch. + if (result case {'duplicates_dropped': final int dropped}) { + duplicates += dropped.clamp(0, batch.length); } } return (retry: retry, rejected: rejected, duplicates: duplicates); diff --git a/posthog_flutter/test/core/capture_v1_test.dart b/posthog_flutter/test/core/capture_v1_test.dart index 822065a3..7dda174f 100644 --- a/posthog_flutter/test/core/capture_v1_test.dart +++ b/posthog_flutter/test/core/capture_v1_test.dart @@ -374,6 +374,18 @@ void main() { expect(results.retry, {'b'}); }); + test('clamps malformed duplicates_dropped to the batch size', () { + final body = jsonEncode({ + 'results': { + 'a': {'result': 'ok', 'duplicates_dropped': 9007199254740991}, + 'b': {'result': 'ok', 'duplicates_dropped': -5}, + 'c': {'result': 'ok', 'duplicates_dropped': '2'}, + }, + }); + + expect(captureV1Results(body, ['a', 'b', 'c'])!.duplicates, 3); + }); + test('counts a 2xx without readable results as delivered', () async { final server = await LocalPostHogServer.start(); final storage = tempStorage(); From d515a33305038ea1664601bd73878aaf5bb4353a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 10:17:49 -0400 Subject: [PATCH 3/4] fix(desktop): clamp duplicates_dropped against every copy sent Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/src/core/capture_v1.dart | 4 +++- .../lib/src/core/posthog_core_stateless.dart | 7 ++++--- posthog_flutter/test/core/capture_v1_test.dart | 13 +++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/posthog_flutter/lib/src/core/capture_v1.dart b/posthog_flutter/lib/src/core/capture_v1.dart index 6d26fa63..66eb23b3 100644 --- a/posthog_flutter/lib/src/core/capture_v1.dart +++ b/posthog_flutter/lib/src/core/capture_v1.dart @@ -155,6 +155,8 @@ class CaptureV1Identity { /// many repeated copies of them it dropped (`duplicates_dropped`), or null /// when the body has no readable per-event results. /// +/// [uuids] holds one entry per event sent, repeats included. +/// /// `ok`, `warning`, `drop`, `limited` and any unknown result are final. /// An event missing from the results counts as delivered. ({Set retry, int rejected, int duplicates})? captureV1Results( @@ -174,7 +176,7 @@ class CaptureV1Identity { final retry = {}; var rejected = 0; var duplicates = 0; - for (final uuid in batch) { + for (final uuid in batch.toSet()) { final result = results[uuid]; switch (result) { case {'result': 'retry'}: diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 9546b16b..9b49b24c 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -750,10 +750,11 @@ abstract class PostHogCoreStateless { _CaptureLane lane, List batch, ) async { - final uuids = { + final eventUuids = [ for (final queued in batch) if (queued.event['uuid'] case final String uuid) uuid, - }; + ]; + final uuids = eventUuids.toSet(); final pending = lane.pendingIdentity; final identity = pending != null && sameUuids(pending.uuids, uuids) ? pending.next() @@ -793,7 +794,7 @@ abstract class PostHogCoreStateless { ); } - final results = captureV1Results(response.body, uuids); + final results = captureV1Results(response.body, eventUuids); if (results == null) { logger.info('Capture returned ${response.status} without readable ' 'per-event results; ${uuids.length} events count as delivered.'); diff --git a/posthog_flutter/test/core/capture_v1_test.dart b/posthog_flutter/test/core/capture_v1_test.dart index 7dda174f..bb81d6f4 100644 --- a/posthog_flutter/test/core/capture_v1_test.dart +++ b/posthog_flutter/test/core/capture_v1_test.dart @@ -386,6 +386,19 @@ void main() { expect(captureV1Results(body, ['a', 'b', 'c'])!.duplicates, 3); }); + test('clamps duplicates_dropped against every copy sent', () { + final body = jsonEncode({ + 'results': { + 'a': {'result': 'drop', 'duplicates_dropped': 2}, + }, + }); + + final results = captureV1Results(body, ['a', 'a', 'a'])!; + + expect(results.duplicates, 2); + expect(results.rejected, 1); + }); + test('counts a 2xx without readable results as delivered', () async { final server = await LocalPostHogServer.start(); final storage = tempStorage(); From 99420766639612416fc9c3606b6433e233f4d391 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 16:28:16 -0400 Subject: [PATCH 4/4] fix(desktop): drop unreadable 2xx batches and log lost events with endpoint and reasons - A 2xx whose body has no readable per-event results fails the batch for good: it's dropped, not resent, and reported through the error event, matching posthog-ios and posthog-android. - One aggregate line per batch that lost events, naming the endpoint, the count and capture's `details` tags (never uuids or content). A terminal status logs how many events it lost; a multi-event 413 still halves. - A `limited` result counts as delivered, like any unknown result. - The non-string session/window id warning names the JSON type `boolean`. - Test fakes answer capture requests with per-event results, as capture v1 does. Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-desktop-rubric-followups.md | 2 + posthog_flutter/lib/src/core/capture_v1.dart | 28 +++++---- .../lib/src/core/posthog_core_stateless.dart | 47 ++++++++++----- .../test/core/capture_v1_test.dart | 59 +++++++++++++++++-- posthog_flutter/test/core/requests_test.dart | 12 ++-- posthog_flutter/test/posthog_api_fake.dart | 18 +++++- .../test/posthog_flutter_desktop_test.dart | 38 +++++++----- 7 files changed, 148 insertions(+), 56 deletions(-) diff --git a/.changeset/v6-desktop-rubric-followups.md b/.changeset/v6-desktop-rubric-followups.md index 8c801289..0c6b184e 100644 --- a/.changeset/v6-desktop-rubric-followups.md +++ b/.changeset/v6-desktop-rubric-followups.md @@ -4,3 +4,5 @@ - Skip `setup()` with one log line when `projectToken` is a personal API key (`phx_`), so the key is never sent - On Windows and Linux, stop printing capture's response body in flush error logs, and log how many repeated-uuid copies capture dropped +- On Windows and Linux, drop a batch whose 2xx response has no readable per-event results instead of counting it as delivered, and don't resend it +- On Windows and Linux, log one line per batch that lost events, with the endpoint, the count and capture's reasons, also for a terminal status; a `limited` result counts as delivered, like any result the SDK doesn't know diff --git a/posthog_flutter/lib/src/core/capture_v1.dart b/posthog_flutter/lib/src/core/capture_v1.dart index 66eb23b3..c3d0af87 100644 --- a/posthog_flutter/lib/src/core/capture_v1.dart +++ b/posthog_flutter/lib/src/core/capture_v1.dart @@ -109,7 +109,7 @@ String? _liftString( } String _jsonType(Object value) => switch (value) { - bool() => 'bool', + bool() => 'boolean', num() => 'number', Map() => 'object', List() => 'array', @@ -151,15 +151,16 @@ class CaptureV1Identity { } /// The uuids among [uuids] that a capture v1 response [body] asks to -/// retry, with how many of them it rejected (`drop` or `limited`) and how -/// many repeated copies of them it dropped (`duplicates_dropped`), or null -/// when the body has no readable per-event results. +/// retry, with how many of them it dropped (`drop`) by `details` tag and +/// how many repeated copies of them it dropped (`duplicates_dropped`), or +/// null when the body has no readable per-event results. /// /// [uuids] holds one entry per event sent, repeats included. /// -/// `ok`, `warning`, `drop`, `limited` and any unknown result are final. -/// An event missing from the results counts as delivered. -({Set retry, int rejected, int duplicates})? captureV1Results( +/// `ok`, `warning`, `drop` and any unknown result are final; only `drop` +/// counts as dropped. An event missing from the results counts as +/// delivered. +({Set retry, Map drops, int duplicates})? captureV1Results( String? body, Iterable uuids, ) { @@ -174,22 +175,27 @@ class CaptureV1Identity { if (results is! Map) return null; final batch = uuids.toList(growable: false); final retry = {}; - var rejected = 0; + final drops = {}; var duplicates = 0; for (final uuid in batch.toSet()) { final result = results[uuid]; switch (result) { case {'result': 'retry'}: retry.add(uuid); - case {'result': 'drop' || 'limited'}: - rejected++; + case {'result': 'drop'}: + // The server's reason, an opaque tag. + final details = switch (result['details']) { + final String tag => tag, + _ => 'no details', + }; + drops[details] = (drops[details] ?? 0) + 1; } // Clamped so a malformed count can't exceed the batch. if (result case {'duplicates_dropped': final int dropped}) { duplicates += dropped.clamp(0, batch.length); } } - return (retry: retry, rejected: rejected, duplicates: duplicates); + return (retry: retry, drops: drops, duplicates: duplicates); } /// Whether two `Set`s hold the same elements. diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 9b49b24c..fe4a5fc3 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -741,11 +741,12 @@ abstract class PostHogCoreStateless { /// delivered or dropped for good. /// /// A 2xx whose per-event results cannot be read, also when the response - /// fails after its status arrived, counts as delivered: the server stored - /// the events, and sending them again would store them twice. + /// fails after its status arrived, fails the whole batch for good. It + /// isn't retried: the server may have stored some of the events, and + /// sending them again would store them twice. /// - /// Throws [PostHogFetchHttpError] for a non-2xx status and - /// [PostHogFetchNetworkError] for a transport failure. + /// Throws [PostHogFetchHttpError] for a non-2xx status or an unreadable + /// 2xx, and [PostHogFetchNetworkError] for a transport failure. Future<({Set retryUuids, Duration? retryAfter})> _postCaptureV1( _CaptureLane lane, List batch, @@ -785,6 +786,12 @@ abstract class PostHogCoreStateless { if (response.status < 200 || response.status >= 300) { if (!isRetriableCaptureV1Status(response.status)) { lane.pendingIdentity = null; + // A multi-event 413 halves the batch and resends it instead. + if (response.status != HttpStatus.requestEntityTooLarge || + batch.length == 1) { + logger.warn('Capture ${lane.path} returned ${response.status}: ' + '${batch.length} events dropped for good.'); + } } throw PostHogFetchHttpError( response.status, @@ -796,19 +803,27 @@ abstract class PostHogCoreStateless { final results = captureV1Results(response.body, eventUuids); if (results == null) { - logger.info('Capture returned ${response.status} without readable ' - 'per-event results; ${uuids.length} events count as delivered.'); - } else { - if (results.rejected > 0) { - logger.warn('Capture dropped ${results.rejected} of ${uuids.length} ' - 'events for good.'); - } - if (results.duplicates > 0) { - logger.warn('Capture dropped ${results.duplicates} copies of events ' - 'with a repeated uuid.'); - } + lane.pendingIdentity = null; + // Status and count only: the body may name event uuids. + logger.warn('Capture ${lane.path} returned ${response.status} without ' + 'readable per-event results: ${batch.length} events dropped for ' + 'good.'); + throw PostHogFetchHttpError(response.status, '', body.length); + } + // Counts and the server's opaque reasons only, one line per batch: + // uuids and event content can identify users. + final dropped = results.drops.values.fold(0, (sum, n) => sum + n); + if (dropped > 0 || results.duplicates > 0) { + final reasons = [ + for (final MapEntry(:key, :value) in results.drops.entries) + '$key: $value', + ].join(', '); + logger.warn('Capture ${lane.path} dropped $dropped of ' + '${batch.length} events for good' + '${reasons.isEmpty ? '' : ' ($reasons)'}' + ' and ${results.duplicates} repeated-uuid copies.'); } - final retryUuids = results?.retry ?? const {}; + final retryUuids = results.retry; lane.pendingIdentity = retryUuids.isEmpty ? null : identity.withUuids(retryUuids); return (retryUuids: retryUuids, retryAfter: response.retryAfter); diff --git a/posthog_flutter/test/core/capture_v1_test.dart b/posthog_flutter/test/core/capture_v1_test.dart index bb81d6f4..c8ea075b 100644 --- a/posthog_flutter/test/core/capture_v1_test.dart +++ b/posthog_flutter/test/core/capture_v1_test.dart @@ -154,6 +154,16 @@ void main() { expect(lines.single, contains('number')); expect(lines.single, isNot(contains('42'))); }); + + test('names the JSON type of a boolean session id', () { + final lines = []; + captureV1Event({ + 'event': 'e', + 'properties': {r'$window_id': true}, + }, CoreLogger((log) => lines.addAll(printedLines(log)))); + + expect(lines.single, contains('a boolean value')); + }); }); group('captureRetryDelay', () { @@ -228,8 +238,10 @@ void main() { final uuids = [ for (final event in api.batchRequests.single.events) event['uuid'], ]; - final rejection = - lines.singleWhere((line) => line.contains('dropped 2 of 7')); + // `limited` and unknown results count as delivered, like `ok`. + final rejection = lines.singleWhere((line) => line + .contains('/i/v1/analytics/events dropped 1 of 7 events for good ' + '(detail: 1)')); for (final uuid in uuids) { expect(rejection, isNot(contains('$uuid'))); } @@ -396,21 +408,56 @@ void main() { final results = captureV1Results(body, ['a', 'a', 'a'])!; expect(results.duplicates, 2); - expect(results.rejected, 1); + expect(results.drops, {'no details': 1}); }); - test('counts a 2xx without readable results as delivered', () async { + test('drops a 2xx without readable results without retrying it', () async { final server = await LocalPostHogServer.start(); final storage = tempStorage(); final client = testClient(server, storage: storage); server.respond = (_) => const PostHogResponse(HttpStatus.ok, body: 'not json'); + final errors = []; + // ignore: invalid_use_of_protected_member + client.events.on('error', errors.add); + final flushed = []; + // ignore: invalid_use_of_protected_member + client.events.on('flush', flushed.add); client.capture('evt'); - await client.flush(); + await expectLater(client.flush(), throwsA(isA())); expect(server.batchRequests, hasLength(1)); expect(getQueue(storage), isEmpty); + expect(errors, hasLength(1)); + expect(flushed, isEmpty); + }); + + test('logs the endpoint and count of a batch lost to a terminal status', + () { + final api = InProcessPostHogApi(); + final storage = tempStorage(); + fakeAsync((async) { + final client = + testClient(api, config: testConfig(debug: true), storage: storage); + api.respond = (request) => request.isFlags + ? PostHogResponse.json({'flags': {}}) + : const PostHogResponse(HttpStatus.badRequest); + client.capture('one'); + client.capture('two'); + + final lines = printedLines(() { + client.flush().catchError((_) {}); + async.elapse(Duration.zero); + }); + + expect(api.batchRequests, hasLength(1)); + expect(queuedEvents(storage), isEmpty); + expect( + lines, + contains(contains('Capture /i/v1/analytics/events returned 400: ' + '2 events dropped for good.'))); + }); }); test('drops a single event that is still too large', () async { @@ -459,7 +506,7 @@ void main() { server.respond = (request) => request.path == '/i/v1/analytics/events' ? PostHogResponse(HttpStatus.temporaryRedirect, location: '${server.url}/moved/events') - : const PostHogResponse(HttpStatus.ok); + : PostHogResponse.results(request, (_) => 'ok'); client.capture('evt'); await client.flush(); diff --git a/posthog_flutter/test/core/requests_test.dart b/posthog_flutter/test/core/requests_test.dart index 96ad2262..f80fef2e 100644 --- a/posthog_flutter/test/core/requests_test.dart +++ b/posthog_flutter/test/core/requests_test.dart @@ -244,6 +244,7 @@ void main() { async.elapse(const Duration(seconds: 6)); body.add(utf8.encode(' ')); } + body.add(utf8.encode('{"results": {}}')); body.close(); async.flushMicrotasks(); @@ -283,7 +284,7 @@ void main() { }); }); - test('counts a 2xx whose body times out as delivered', () { + test('drops a 2xx whose body times out without resending it', () { fakeAsync((async) { // Cancelled in this zone: fake_async does not run the root zone's // microtasks. @@ -300,11 +301,11 @@ void main() { createHttpClient: (_) => http, ); client.capture('evt'); - var flushed = false; - client.flush().then((_) => flushed = true); + Object? error; + client.flush().catchError((Object e) => error = e); async.elapse(const Duration(seconds: 10)); - expect(flushed, isTrue); + expect(error, isA()); async.elapse(const Duration(seconds: 30)); expect(openCalls, 1, reason: 'a resend would store the events a second time'); @@ -335,7 +336,8 @@ class _ControlledHttpClient implements HttpClient { class _ControlledRequest implements HttpClientRequest { _ControlledRequest({HttpClientResponse? response, this.takeChunk}) - : _response = response ?? _ControlledResponse(Stream.value([])); + : _response = response ?? + _ControlledResponse(Stream.value(utf8.encode('{"results": {}}'))); final HttpClientResponse _response; diff --git a/posthog_flutter/test/posthog_api_fake.dart b/posthog_flutter/test/posthog_api_fake.dart index d3b52fd2..f20606da 100644 --- a/posthog_flutter/test/posthog_api_fake.dart +++ b/posthog_flutter/test/posthog_api_fake.dart @@ -55,9 +55,12 @@ class PostHogRequest { /// How the PostHog API answers a request. class PostHogResponse { const PostHogResponse(this.status, - {this.body = '{"status": 1}', this.location, this.headers = const {}}) + {this.body = defaultBody, this.location, this.headers = const {}}) : isDropped = false; + /// The body of a response that sets none. + static const defaultBody = '{"status": 1}'; + /// A success with [json] as its body. PostHogResponse.json(Object? json, {Map headers = const {}}) : this(HttpStatus.ok, body: jsonEncode(json), headers: headers); @@ -169,7 +172,18 @@ abstract class PostHogApiFake { waiter.done.complete(); return true; }); - return respond(request); + final response = await respond(request); + // Capture v1 answers every 2xx with per-event results. A success left + // with the default body gets an `ok` result for each event, so only a + // test that sets a body of its own sends an unreadable one. + if ((request.isBatch || request.isAi) && + response.status >= 200 && + response.status < 300 && + identical(response.body, PostHogResponse.defaultBody)) { + return PostHogResponse.results(request, (_) => 'ok', + headers: response.headers); + } + return response; } Future _waitUntil(bool Function() isReady) { diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 22c757da..53cfeae3 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -775,7 +775,7 @@ void main() { test('properties the callbacks remove stay removed', () async { final server = await LocalPostHogServer.start(); - server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + server.respond = _answerFlags(_variantFlagResponse()); final flagsLoaded = Completer(); Map? seen; final removed = [ @@ -830,7 +830,7 @@ void main() { test('SDK-internal events bypass the callbacks', () async { final server = await LocalPostHogServer.start(); - server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + server.respond = _answerFlags(_variantFlagResponse()); final flagsLoaded = Completer(); final seenEventNames = []; final config = configFor(server) @@ -2186,7 +2186,7 @@ void main() { r'getFeatureFlagResult with sendFeatureFlagEvent false does not capture $feature_flag_called', () async { final server = await LocalPostHogServer.start(); - server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + server.respond = _answerFlags(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) ..onFeatureFlags = (_) { @@ -2216,7 +2216,7 @@ void main() { 'captureFeatureView and captureFeatureInteraction match the native event shape', () async { final server = await LocalPostHogServer.start(); - server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + server.respond = _answerFlags(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) ..onFeatureFlags = (_) { @@ -2252,7 +2252,7 @@ void main() { test('captureFeatureView keeps the user it was called for across reset', () async { final server = await LocalPostHogServer.start(); - server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + server.respond = _answerFlags(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) ..onFeatureFlags = (_) { @@ -2271,7 +2271,7 @@ void main() { test(r'sendFeatureFlagEvent false skips $feature_flag_called', () async { final server = await LocalPostHogServer.start(); - server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + server.respond = _answerFlags(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) ..onFeatureFlags = (_) { @@ -2297,7 +2297,7 @@ void main() { r'getFeatureFlag follows config.sendFeatureFlagEvent unless overridden', () async { final server = await LocalPostHogServer.start(); - server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + server.respond = _answerFlags(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) ..sendFeatureFlagEvent = false @@ -2323,12 +2323,12 @@ void main() { r'getAllFeatureFlags returns every loaded flag without capturing $feature_flag_called', () async { final server = await LocalPostHogServer.start(); - server.respond = (_) => PostHogResponse.json({ - 'flags': { - ..._variantFlagResponse()['flags']! as Map, - 'off-flag': {'key': 'off-flag', 'enabled': false}, - }, - }); + server.respond = _answerFlags({ + 'flags': { + ..._variantFlagResponse()['flags']! as Map, + 'off-flag': {'key': 'off-flag', 'enabled': false}, + }, + }); final platform = await setUpPlatform( configFor(server)..preloadFeatureFlags = false, ); @@ -2356,7 +2356,7 @@ void main() { 'reloadFeatureFlags returns the loaded flags, and the last known ones when loading fails', () async { final server = await LocalPostHogServer.start(); - server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + server.respond = _answerFlags(_variantFlagResponse()); final announced = []; final platform = await setUpPlatform(configFor(server) ..preloadFeatureFlags = false @@ -2377,7 +2377,7 @@ void main() { r'getFeatureFlagResult follows config.sendFeatureFlagEvent unless overridden', () async { final server = await LocalPostHogServer.start(); - server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + server.respond = _answerFlags(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) ..sendFeatureFlagEvent = false @@ -2403,7 +2403,7 @@ void main() { test('onFeatureFlags reports the flags cached by the previous launch', () async { final server = await LocalPostHogServer.start(); - server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + server.respond = _answerFlags(_variantFlagResponse()); final appDirectory = createAppDirectory(); final previousLaunch = createPlatform(appDirectory: appDirectory); await previousLaunch @@ -2467,3 +2467,9 @@ Map _variantFlagResponse() => { }, }, }; + +/// Answers `/flags/` with [flags], and every other request with a success. +PostHogResponder _answerFlags(Map flags) => + (request) => request.isFlags + ? PostHogResponse.json(flags) + : const PostHogResponse(HttpStatus.ok);