Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/v6-major-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
- Add `captureAi` for AI observability events, a no-op that returns `null` on web
- Add `PostHogConfig.captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes`
- Apply a `$process_person_profile` property or `process_person_profile` option passed with an event to that event on Windows and Linux
- Skip `setup()` with one log line when `projectToken` is a personal API key (`phx_`), so the key is never sent
- Stop printing capture's response body in flush error logs on Windows and Linux, and log how many repeated-uuid copies capture dropped
- Drop a batch whose 2xx response has no readable per-event results on Windows and Linux instead of counting it as delivered, without resending it
- Log one line per batch that lost events on Windows and Linux, with the endpoint, count and reasons, also for a terminal status, and count a `limited` result as delivered
- Back off failed sends on Windows and Linux from 1 second up to 30 seconds with ±25% jitter, instead of resending within the flush, treating `Retry-After` as a floor
- Take an event's timestamp before the `beforeSend` callbacks run on Windows and Linux
- Declare Android `minSdkVersion` 23 to match `posthog-android`
6 changes: 5 additions & 1 deletion example/integration_test/desktop_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,11 @@ class _FakePostHog {
switch (request.uri.path) {
case '/i/v1/analytics/events':
response.statusCode = batchStatus;
if (batchStatus == HttpStatus.ok) _batches.add(body);
if (batchStatus == HttpStatus.ok) {
_batches.add(body);
response.headers.contentType = ContentType.json;
response.write(jsonEncode({'results': <String, Object?>{}}));
}
case '/flags/':
_flagsRequests.add(body);
response.headers.contentType = ContentType.json;
Expand Down
4 changes: 4 additions & 0 deletions posthog_flutter/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ On iOS and macOS, string values in `PostHogBootstrapConfig.featureFlagPayloads`,

A path that needs user action, such as a VPN waiting for a password, counts as reachable, and queues pause while cellular data is off for the app. Events captured in the first 100 ms after setup may leave out `$network_wifi` and `$network_cellular`. Events still on disk from a `posthog-ios` 2.x SDK are deleted instead of sent.

### Personal API keys

A personal API key (`phx_...`) in `projectToken` now skips `setup()` with one log line, so the SDK doesn't start and the key is never sent. Use your project token (`phc_...`).

### Windows and Linux

- **Property order:** an event's properties are the SDK's, then registered properties, then the event's own, so your value now wins for that event. `beforeSend` runs after that merge, except that `$lib` and `$lib_version` always come from the `PostHog-Sdk-Info` header.
Expand Down
43 changes: 31 additions & 12 deletions posthog_flutter/lib/src/core/capture_v1.dart
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ String? _liftString(
}

String _jsonType(Object value) => switch (value) {
bool() => 'bool',
bool() => 'boolean',
num() => 'number',
Map() => 'object',
List() => 'array',
Expand Down Expand Up @@ -150,13 +150,19 @@ class CaptureV1Identity {
);
}

final _captureV1DropTag = RegExp(r'^[a-z0-9_]{1,64}$');

/// 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
/// 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<String> retry, int rejected})? 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<String> retry, Map<String, int> drops, int duplicates})? captureV1Results(
String? body,
Iterable<String> uuids,
) {
Expand All @@ -169,17 +175,30 @@ class CaptureV1Identity {
return null;
}
if (results is! Map) return null;
final batch = uuids.toList(growable: false);
final retry = <String>{};
var rejected = 0;
for (final uuid in uuids) {
switch (results[uuid]) {
final drops = <String, int>{};
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'}:
// These tags reach the logs, so anything but a reason code, such as
// an echoed id or a forged log line, is discarded.
final details = switch (result['details']) {
final String tag when _captureV1DropTag.hasMatch(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);
Comment thread
turnipdabeets marked this conversation as resolved.
Resolved
}
}
return (retry: retry, rejected: rejected);
return (retry: retry, drops: drops, duplicates: duplicates);
}

/// Whether two `Set`s hold the same elements.
Expand Down
54 changes: 39 additions & 15 deletions posthog_flutter/lib/src/core/posthog_core_stateless.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -622,7 +624,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);
});
}

Expand Down Expand Up @@ -748,19 +750,21 @@ 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<String> retryUuids, Duration? retryAfter})> _postCaptureV1(
_CaptureLane lane,
List<PostHogQueuedEvent> 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()
Expand Down Expand Up @@ -791,6 +795,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,
Expand All @@ -800,15 +810,29 @@ abstract class PostHogCoreStateless {
);
}

final results = captureV1Results(response.body, uuids);
final results = captureV1Results(response.body, eventUuids);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Linux and Windows desktop integration tests fail with this change: the example app's test server (example/integration_test/desktop_test.dart, /i/v1/analytics/events) answers with a bare 200 and no body, so this check now drops the batch and throws. Capture v1 always returns {"results": {...}}, and an empty {"results": {}} counts every event as delivered, so can the test server send that instead? The check itself matches the other SDKs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7dbc213 — the test server now answers {"results": {}}.

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.');
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)'}'
Comment thread
turnipdabeets marked this conversation as resolved.
Resolved
' and ${results.duplicates} repeated-uuid copies.');
}
final retryUuids = results?.retry ?? const <String>{};
final retryUuids = results.retry;
lane.pendingIdentity =
retryUuids.isEmpty ? null : identity.withUuids(retryUuids);
return (retryUuids: retryUuids, retryAfter: response.retryAfter);
Expand Down
8 changes: 8 additions & 0 deletions posthog_flutter/lib/src/posthog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ class Posthog {
);
return Future<void>.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<void>.value();
}

if (!config.enableWindowsAndLinux &&
!kIsWeb &&
Expand Down
113 changes: 108 additions & 5 deletions posthog_flutter/test/core/capture_v1_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <String>[];
captureV1Event({
'event': 'e',
'properties': {r'$window_id': true},
}, CoreLogger((log) => lines.addAll(printedLines(log))));

expect(lines.single, contains('a boolean value'));
});
});

group('captureRetryDelay', () {
Expand Down Expand Up @@ -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')));
}
Expand Down Expand Up @@ -359,18 +371,109 @@ void main() {
});
});

test('counts a 2xx without readable results as delivered', () async {
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('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('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.drops, {'no details': 1});
});

test('keeps only reason codes as drop reasons', () {
final body = jsonEncode({
'results': {
'a': {'result': 'drop', 'details': 'event_too_big'},
'b': {'result': 'drop', 'details': 'x\n[PostHog] forged'},
'c': {'result': 'drop', 'details': '[email protected]'},
'd': {'result': 'drop', 'details': 'a' * 65},
},
});

expect(captureV1Results(body, ['a', 'b', 'c', 'd'])!.drops, {
'event_too_big': 1,
'no details': 3,
});
});

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 = <Object?>[];
// ignore: invalid_use_of_protected_member
client.events.on('error', errors.add);
final flushed = <Object?>[];
// ignore: invalid_use_of_protected_member
client.events.on('flush', flushed.add);

client.capture('evt');
await client.flush();
await expectLater(client.flush(), throwsA(isA<PostHogFetchHttpError>()));

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': <String, Object?>{}})
: 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 {
Expand Down Expand Up @@ -419,7 +522,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();
Expand Down
Loading
Loading