From e7c2d09214fa9c3ec0a899144dbcc798844ad9f8 Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Tue, 29 Sep 2026 13:45:51 +0300 Subject: [PATCH 01/22] =?UTF-8?q?=E2=9C=A8=20[feat]=20add=20windows=20and?= =?UTF-8?q?=20linux=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/desktop-dart-sdk.md | 5 + .github/workflows/ci.yml | 76 ++ api/posthog_flutter.api.json | 30 + example/.metadata | 6 + example/linux/.gitignore | 1 + example/linux/CMakeLists.txt | 128 ++ example/linux/flutter/CMakeLists.txt | 88 ++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + example/linux/flutter/generated_plugins.cmake | 23 + example/linux/runner/CMakeLists.txt | 26 + example/linux/runner/main.cc | 6 + example/linux/runner/my_application.cc | 148 +++ example/linux/runner/my_application.h | 21 + example/windows/.gitignore | 17 + example/windows/CMakeLists.txt | 108 ++ example/windows/flutter/CMakeLists.txt | 109 ++ .../flutter/generated_plugin_registrant.cc | 11 + .../flutter/generated_plugin_registrant.h | 15 + .../windows/flutter/generated_plugins.cmake | 23 + example/windows/runner/CMakeLists.txt | 40 + example/windows/runner/Runner.rc | 121 ++ example/windows/runner/flutter_window.cpp | 71 + example/windows/runner/flutter_window.h | 33 + example/windows/runner/main.cpp | 43 + example/windows/runner/resource.h | 16 + example/windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes example/windows/runner/runner.exe.manifest | 14 + example/windows/runner/utils.cpp | 69 + example/windows/runner/utils.h | 19 + example/windows/runner/win32_window.cpp | 288 ++++ example/windows/runner/win32_window.h | 102 ++ posthog_flutter/lib/posthog_flutter_dart.dart | 27 + posthog_flutter/lib/src/core/PORTING_NOTES.md | 73 + .../lib/src/core/event_emitter.dart | 51 + .../lib/src/core/feature_flag_utils.dart | 125 ++ .../lib/src/core/feature_flags.dart | 302 +++++ .../lib/src/core/file_storage.dart | 422 ++++++ posthog_flutter/lib/src/core/logger.dart | 25 + posthog_flutter/lib/src/core/persistence.dart | 16 + .../lib/src/core/posthog_core.dart | 1006 ++++++++++++++ .../lib/src/core/posthog_core_stateless.dart | 656 +++++++++ .../lib/src/core/session_manager.dart | 48 + posthog_flutter/lib/src/core/utils/utils.dart | 137 ++ posthog_flutter/lib/src/core/uuid.dart | 76 ++ ...rror_tracking_autocapture_integration.dart | 13 +- posthog_flutter/lib/src/posthog.dart | 16 +- posthog_flutter/lib/src/posthog_config.dart | 25 +- .../lib/src/posthog_desktop_app_info.dart | 80 ++ .../lib/src/posthog_desktop_client.dart | 64 + .../lib/src/posthog_desktop_context.dart | 76 ++ .../src/posthog_desktop_exception_steps.dart | 86 ++ .../lib/src/posthog_desktop_lifecycle.dart | 200 +++ .../lib/src/posthog_desktop_storage.dart | 64 + .../lib/src/posthog_desktop_time_zone.dart | 102 ++ .../src/posthog_desktop_version_resource.dart | 138 ++ .../lib/src/posthog_flutter_desktop.dart | 626 +++++++++ posthog_flutter/lib/src/posthog_widget.dart | 17 +- posthog_flutter/pubspec.yaml | 9 +- posthog_flutter/test/core/bootstrap_test.dart | 306 +++++ .../test/core/compression_test.dart | 28 + .../test/core/context_properties_test.dart | 71 + .../core/desktop_parity_regressions_test.dart | 164 +++ .../test/core/feature_flags_test.dart | 717 ++++++++++ .../test/core/file_queue_test.dart | 185 +++ .../test/core/file_storage_lock_test.dart | 158 +++ .../test/core/file_storage_test.dart | 292 ++++ .../test/core/legacy_flags_test.dart | 185 +++ posthog_flutter/test/core/opt_out_test.dart | 202 +++ .../test/core/posthog_core_test.dart | 648 +++++++++ .../test/core/queue_flush_test.dart | 774 +++++++++++ posthog_flutter/test/core/requests_test.dart | 88 ++ .../test/core/session_manager_test.dart | 60 + posthog_flutter/test/core/test_client.dart | 132 ++ posthog_flutter/test/core/uuid_test.dart | 55 + .../test/event_shape_snapshot_test.dart | 2 +- .../test/native_occlusion_episode_test.dart | 17 + posthog_flutter/test/posthog_api_fake.dart | 317 +++++ .../test/posthog_desktop_app_info_test.dart | 95 ++ .../test/posthog_desktop_context_test.dart | 93 ++ .../posthog_desktop_exception_steps_test.dart | 126 ++ .../test/posthog_desktop_lifecycle_test.dart | 260 ++++ .../test/posthog_desktop_storage_test.dart | 84 ++ .../test/posthog_desktop_time_zone_test.dart | 82 ++ .../test/posthog_desktop_transport_test.dart | 109 ++ .../test/posthog_flutter_dart_test.dart | 24 + .../test/posthog_flutter_desktop_test.dart | 1181 +++++++++++++++++ 87 files changed, 12591 insertions(+), 27 deletions(-) create mode 100644 .changeset/desktop-dart-sdk.md create mode 100644 example/linux/.gitignore create mode 100644 example/linux/CMakeLists.txt create mode 100644 example/linux/flutter/CMakeLists.txt create mode 100644 example/linux/flutter/generated_plugin_registrant.cc create mode 100644 example/linux/flutter/generated_plugin_registrant.h create mode 100644 example/linux/flutter/generated_plugins.cmake create mode 100644 example/linux/runner/CMakeLists.txt create mode 100644 example/linux/runner/main.cc create mode 100644 example/linux/runner/my_application.cc create mode 100644 example/linux/runner/my_application.h create mode 100644 example/windows/.gitignore create mode 100644 example/windows/CMakeLists.txt create mode 100644 example/windows/flutter/CMakeLists.txt create mode 100644 example/windows/flutter/generated_plugin_registrant.cc create mode 100644 example/windows/flutter/generated_plugin_registrant.h create mode 100644 example/windows/flutter/generated_plugins.cmake create mode 100644 example/windows/runner/CMakeLists.txt create mode 100644 example/windows/runner/Runner.rc create mode 100644 example/windows/runner/flutter_window.cpp create mode 100644 example/windows/runner/flutter_window.h create mode 100644 example/windows/runner/main.cpp create mode 100644 example/windows/runner/resource.h create mode 100644 example/windows/runner/resources/app_icon.ico create mode 100644 example/windows/runner/runner.exe.manifest create mode 100644 example/windows/runner/utils.cpp create mode 100644 example/windows/runner/utils.h create mode 100644 example/windows/runner/win32_window.cpp create mode 100644 example/windows/runner/win32_window.h create mode 100644 posthog_flutter/lib/posthog_flutter_dart.dart create mode 100644 posthog_flutter/lib/src/core/PORTING_NOTES.md create mode 100644 posthog_flutter/lib/src/core/event_emitter.dart create mode 100644 posthog_flutter/lib/src/core/feature_flag_utils.dart create mode 100644 posthog_flutter/lib/src/core/feature_flags.dart create mode 100644 posthog_flutter/lib/src/core/file_storage.dart create mode 100644 posthog_flutter/lib/src/core/logger.dart create mode 100644 posthog_flutter/lib/src/core/persistence.dart create mode 100644 posthog_flutter/lib/src/core/posthog_core.dart create mode 100644 posthog_flutter/lib/src/core/posthog_core_stateless.dart create mode 100644 posthog_flutter/lib/src/core/session_manager.dart create mode 100644 posthog_flutter/lib/src/core/utils/utils.dart create mode 100644 posthog_flutter/lib/src/core/uuid.dart create mode 100644 posthog_flutter/lib/src/posthog_desktop_app_info.dart create mode 100644 posthog_flutter/lib/src/posthog_desktop_client.dart create mode 100644 posthog_flutter/lib/src/posthog_desktop_context.dart create mode 100644 posthog_flutter/lib/src/posthog_desktop_exception_steps.dart create mode 100644 posthog_flutter/lib/src/posthog_desktop_lifecycle.dart create mode 100644 posthog_flutter/lib/src/posthog_desktop_storage.dart create mode 100644 posthog_flutter/lib/src/posthog_desktop_time_zone.dart create mode 100644 posthog_flutter/lib/src/posthog_desktop_version_resource.dart create mode 100644 posthog_flutter/lib/src/posthog_flutter_desktop.dart create mode 100644 posthog_flutter/test/core/bootstrap_test.dart create mode 100644 posthog_flutter/test/core/compression_test.dart create mode 100644 posthog_flutter/test/core/context_properties_test.dart create mode 100644 posthog_flutter/test/core/desktop_parity_regressions_test.dart create mode 100644 posthog_flutter/test/core/feature_flags_test.dart create mode 100644 posthog_flutter/test/core/file_queue_test.dart create mode 100644 posthog_flutter/test/core/file_storage_lock_test.dart create mode 100644 posthog_flutter/test/core/file_storage_test.dart create mode 100644 posthog_flutter/test/core/legacy_flags_test.dart create mode 100644 posthog_flutter/test/core/opt_out_test.dart create mode 100644 posthog_flutter/test/core/posthog_core_test.dart create mode 100644 posthog_flutter/test/core/queue_flush_test.dart create mode 100644 posthog_flutter/test/core/requests_test.dart create mode 100644 posthog_flutter/test/core/session_manager_test.dart create mode 100644 posthog_flutter/test/core/test_client.dart create mode 100644 posthog_flutter/test/core/uuid_test.dart create mode 100644 posthog_flutter/test/posthog_api_fake.dart create mode 100644 posthog_flutter/test/posthog_desktop_app_info_test.dart create mode 100644 posthog_flutter/test/posthog_desktop_context_test.dart create mode 100644 posthog_flutter/test/posthog_desktop_exception_steps_test.dart create mode 100644 posthog_flutter/test/posthog_desktop_lifecycle_test.dart create mode 100644 posthog_flutter/test/posthog_desktop_storage_test.dart create mode 100644 posthog_flutter/test/posthog_desktop_time_zone_test.dart create mode 100644 posthog_flutter/test/posthog_desktop_transport_test.dart create mode 100644 posthog_flutter/test/posthog_flutter_dart_test.dart create mode 100644 posthog_flutter/test/posthog_flutter_desktop_test.dart diff --git a/.changeset/desktop-dart-sdk.md b/.changeset/desktop-dart-sdk.md new file mode 100644 index 00000000..92be0812 --- /dev/null +++ b/.changeset/desktop-dart-sdk.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +Add Windows and Linux support, set up with the same `Posthog().setup(config)` as the other platforms. Event and screen capture, exception capture (including autocapture of Flutter and Dart errors, and exception steps), identify, alias, groups, super properties, feature flags, bootstrap, opt-out and application lifecycle events work on both. The app name, version and build number come from the pubspec as built by Flutter, and the SDK keeps its state in the user's application data directory (`%APPDATA%\posthog` on Windows, `$XDG_DATA_HOME/posthog` or `~/.local/share/posthog` on Linux). Events include `$timezone` when the SDK can determine a local IANA time zone; on Windows, this uses the system ICU data on a best-effort basis. Session replay, surveys, logs, push notifications and native crash capture are not supported on Windows and Linux. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a392bf4a..a63e11b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,82 @@ jobs: working-directory: ./posthog_flutter run: flutter test --platform chrome --wasm test/posthog_isolate_error_handler_web_test.dart + test-build-windows: + needs: detect-markdown-only + name: Windows test and build + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Complete markdown-only PR check + if: needs.detect-markdown-only.outputs.markdown_only == 'true' + run: echo "Only Markdown files changed; no additional work is required for this check." + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + + # axi92/flutter-action is a fork of subosito/flutter-action that SHA-pins its internal + # actions/cache calls; subosito itself uses tag refs which conflicts with our org-wide + # "Require actions to be pinned to a full-length commit SHA" policy. Tracks upstream. + - uses: axi92/flutter-action@36d2c2625bac6ea011cd7808d2a01bd8a7e5c766 # feature/pin-action-sha @ 2026-04-30 + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + with: + channel: 'stable' + cache: true + + - name: Install dependencies + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + run: flutter pub get + + - name: Test on Windows + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + working-directory: ./posthog_flutter + run: flutter test + + - name: Build Windows example + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + working-directory: ./example + run: flutter build windows --release + + test-build-linux: + needs: detect-markdown-only + name: Linux test and build + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Complete markdown-only PR check + if: needs.detect-markdown-only.outputs.markdown_only == 'true' + run: echo "Only Markdown files changed; no additional work is required for this check." + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + + - name: Install Linux build dependencies + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + run: | + sudo apt-get update -y + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libstdc++-12-dev + + # axi92/flutter-action is a fork of subosito/flutter-action that SHA-pins its internal + # actions/cache calls; subosito itself uses tag refs which conflicts with our org-wide + # "Require actions to be pinned to a full-length commit SHA" policy. Tracks upstream. + - uses: axi92/flutter-action@36d2c2625bac6ea011cd7808d2a01bd8a7e5c766 # feature/pin-action-sha @ 2026-04-30 + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + with: + channel: 'stable' + cache: true + + - name: Install dependencies + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + run: flutter pub get + + - name: Test on Linux + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + working-directory: ./posthog_flutter + run: flutter test + + - name: Build Linux example + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + working-directory: ./example + run: flutter build linux --release + publish-dry-run: needs: detect-markdown-only name: Pub publish dry run diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index 44a08b7a..9443e5f6 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -68,6 +68,36 @@ ], "fieldDeclarations": [], "interfaceDeclarations": [ + { + "entryPoints": [ + "posthog_flutter_dart.dart" + ], + "executableDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": true, + "name": "registerWith", + "parameters": [], + "relativePath": "lib/posthog_flutter_dart.dart", + "returnTypeName": "void", + "type": "method", + "typeParameterNames": [] + } + ], + "fieldDeclarations": [], + "isDeprecated": false, + "isExperimental": false, + "isRequired": false, + "isSealed": false, + "name": "PosthogFlutterDart", + "relativePath": "lib/posthog_flutter_dart.dart", + "superTypeNames": [ + "Object" + ], + "typeParameterNames": [] + }, { "entryPoints": [ "posthog_flutter_web.dart" diff --git a/example/.metadata b/example/.metadata index 13e63571..02a0231d 100644 --- a/example/.metadata +++ b/example/.metadata @@ -18,6 +18,12 @@ migration: - platform: macos create_revision: 78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9 base_revision: 78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9 + - platform: windows + create_revision: 78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9 + base_revision: 78666c8dc57e9f7548ca9f8dd0740fbf0c658dc9 + - platform: linux + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 # User provided section diff --git a/example/linux/.gitignore b/example/linux/.gitignore new file mode 100644 index 00000000..d3896c98 --- /dev/null +++ b/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/example/linux/CMakeLists.txt b/example/linux/CMakeLists.txt new file mode 100644 index 00000000..972d448a --- /dev/null +++ b/example/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "posthog_flutter_example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.posthog_flutter_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/example/linux/flutter/CMakeLists.txt b/example/linux/flutter/CMakeLists.txt new file mode 100644 index 00000000..d5bd0164 --- /dev/null +++ b/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/example/linux/flutter/generated_plugin_registrant.cc b/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..e71a16d2 --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/example/linux/flutter/generated_plugin_registrant.h b/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..e0f0a47b --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 00000000..2e1de87a --- /dev/null +++ b/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/example/linux/runner/CMakeLists.txt b/example/linux/runner/CMakeLists.txt new file mode 100644 index 00000000..e97dabc7 --- /dev/null +++ b/example/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/example/linux/runner/main.cc b/example/linux/runner/main.cc new file mode 100644 index 00000000..e7c5c543 --- /dev/null +++ b/example/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/example/linux/runner/my_application.cc b/example/linux/runner/my_application.cc new file mode 100644 index 00000000..9dd999d2 --- /dev/null +++ b/example/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "posthog_flutter_example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "posthog_flutter_example"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/example/linux/runner/my_application.h b/example/linux/runner/my_application.h new file mode 100644 index 00000000..db16367a --- /dev/null +++ b/example/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/example/windows/.gitignore b/example/windows/.gitignore new file mode 100644 index 00000000..d492d0d9 --- /dev/null +++ b/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/example/windows/CMakeLists.txt b/example/windows/CMakeLists.txt new file mode 100644 index 00000000..d9e6d93f --- /dev/null +++ b/example/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(posthog_flutter_example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "posthog_flutter_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/example/windows/flutter/CMakeLists.txt b/example/windows/flutter/CMakeLists.txt new file mode 100644 index 00000000..903f4899 --- /dev/null +++ b/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..8b6d4680 --- /dev/null +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/example/windows/flutter/generated_plugin_registrant.h b/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..dc139d85 --- /dev/null +++ b/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 00000000..b93c4c30 --- /dev/null +++ b/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/example/windows/runner/CMakeLists.txt b/example/windows/runner/CMakeLists.txt new file mode 100644 index 00000000..394917c0 --- /dev/null +++ b/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/example/windows/runner/Runner.rc b/example/windows/runner/Runner.rc new file mode 100644 index 00000000..a640977e --- /dev/null +++ b/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "posthog_flutter_example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "posthog_flutter_example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "posthog_flutter_example.exe" "\0" + VALUE "ProductName", "posthog_flutter_example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/example/windows/runner/flutter_window.cpp b/example/windows/runner/flutter_window.cpp new file mode 100644 index 00000000..955ee303 --- /dev/null +++ b/example/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/example/windows/runner/flutter_window.h b/example/windows/runner/flutter_window.h new file mode 100644 index 00000000..6da0652f --- /dev/null +++ b/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/example/windows/runner/main.cpp b/example/windows/runner/main.cpp new file mode 100644 index 00000000..a9882fd4 --- /dev/null +++ b/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"posthog_flutter_example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/example/windows/runner/resource.h b/example/windows/runner/resource.h new file mode 100644 index 00000000..66a65d1e --- /dev/null +++ b/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/example/windows/runner/resources/app_icon.ico b/example/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 zcmeHQc|26z|35SKE&G-*mXah&B~fFkXr)DEO&hIfqby^T&>|8^_Ub8Vp#`BLl3lbZ zvPO!8k!2X>cg~Elr=IVxo~J*a`+9wR=A83c-k-DFd(XM&UI1VKCqM@V;DDtJ09WB} zRaHKiW(GT00brH|0EeTeKVbpbGZg?nK6-j827q-+NFM34gXjqWxJ*a#{b_apGN<-L_m3#8Z26atkEn& ze87Bvv^6vVmM+p+cQ~{u%=NJF>#(d;8{7Q{^rWKWNtf14H}>#&y7$lqmY6xmZryI& z($uy?c5-+cPnt2%)R&(KIWEXww>Cnz{OUpT>W$CbO$h1= z#4BPMkFG1Y)x}Ui+WXr?Z!w!t_hjRq8qTaWpu}FH{MsHlU{>;08goVLm{V<&`itk~ zE_Ys=D(hjiy+5=?=$HGii=Y5)jMe9|wWoD_K07(}edAxh`~LBorOJ!Cf@f{_gNCC| z%{*04ViE!#>@hc1t5bb+NO>ncf@@Dv01K!NxH$3Eg1%)|wLyMDF8^d44lV!_Sr}iEWefOaL z8f?ud3Q%Sen39u|%00W<#!E=-RpGa+H8}{ulxVl4mwpjaU+%2pzmi{3HM)%8vb*~-M9rPUAfGCSos8GUXp02|o~0BTV2l#`>>aFV&_P$ejS;nGwSVP8 zMbOaG7<7eKD>c12VdGH;?2@q7535sa7MN*L@&!m?L`ASG%boY7(&L5imY#EQ$KrBB z4@_tfP5m50(T--qv1BJcD&aiH#b-QC>8#7Fx@3yXlonJI#aEIi=8&ChiVpc#N=5le zM*?rDIdcpawoc5kizv$GEjnveyrp3sY>+5_R5;>`>erS%JolimF=A^EIsAK zsPoVyyUHCgf0aYr&alx`<)eb6Be$m&`JYSuBu=p8j%QlNNp$-5C{b4#RubPb|CAIS zGE=9OFLP7?Hgc{?k45)84biT0k&-C6C%Q}aI~q<(7BL`C#<6HyxaR%!dFx7*o^laG z=!GBF^cwK$IA(sn9y6>60Rw{mYRYkp%$jH z*xQM~+bp)G$_RhtFPYx2HTsWk80+p(uqv9@I9)y{b$7NK53rYL$ezbmRjdXS?V}fj zWxX_feWoLFNm3MG7pMUuFPs$qrQWO9!l2B(SIuy2}S|lHNbHzoE+M2|Zxhjq9+Ws8c{*}x^VAib7SbxJ*Q3EnY5lgI9 z=U^f3IW6T=TWaVj+2N%K3<%Un;CF(wUp`TC&Y|ZjyFu6co^uqDDB#EP?DV5v_dw~E zIRK*BoY9y-G_ToU2V_XCX4nJ32~`czdjT!zwme zGgJ0nOk3U4@IE5JwtM}pwimLjk{ln^*4HMU%Fl4~n(cnsLB}Ja-jUM>xIB%aY;Nq8 z)Fp8dv1tkqKanv<68o@cN|%thj$+f;zGSO7H#b+eMAV8xH$hLggtt?O?;oYEgbq@= zV(u9bbd12^%;?nyk6&$GPI%|+<_mEpJGNfl*`!KV;VfmZWw{n{rnZ51?}FDh8we_L z8OI9nE31skDqJ5Oa_ybn7|5@ui>aC`s34p4ZEu6-s!%{uU45$Zd1=p$^^dZBh zu<*pDDPLW+c>iWO$&Z_*{VSQKg7=YEpS3PssPn1U!lSm6eZIho*{@&20e4Y_lRklKDTUCKI%o4Pc<|G^Xgu$J^Q|B87U;`c1zGwf^-zH*VQ^x+i^OUWE0yd z;{FJq)2w!%`x7yg@>uGFFf-XJl4H`YtUG%0slGKOlXV`q?RP>AEWg#x!b{0RicxGhS!3$p7 zij;{gm!_u@D4$Ox%>>bPtLJ> zwKtYz?T_DR1jN>DkkfGU^<#6sGz|~p*I{y`aZ>^Di#TC|Z!7j_O1=Wo8thuit?WxR zh9_S>kw^{V^|g}HRUF=dcq>?q(pHxw!8rx4dC6vbQVmIhmICF#zU!HkHpQ>9S%Uo( zMw{eC+`&pb=GZRou|3;Po1}m46H6NGd$t<2mQh}kaK-WFfmj_66_17BX0|j-E2fe3Jat}ijpc53 zJV$$;PC<5aW`{*^Z6e5##^`Ed#a0nwJDT#Qq~^e8^JTA=z^Kl>La|(UQ!bI@#ge{Dzz@61p-I)kc2?ZxFt^QQ}f%ldLjO*GPj(5)V9IyuUakJX=~GnTgZ4$5!3E=V#t`yOG4U z(gphZB6u2zsj=qNFLYShhg$}lNpO`P9xOSnO*$@@UdMYES*{jJVj|9z-}F^riksLK zbsU+4-{281P9e2UjY6tse^&a)WM1MFw;p#_dHhWI7p&U*9TR0zKdVuQed%6{otTsq z$f~S!;wg#Bd9kez=Br{m|66Wv z#g1xMup<0)H;c2ZO6su_ii&m8j&+jJz4iKnGZ&wxoQX|5a>v&_e#6WA!MB_4asTxLRGQCC5cI(em z%$ZfeqP>!*q5kU>a+BO&ln=4Jm>Ef(QE8o&RgLkk%2}4Tf}U%IFP&uS7}&|Q-)`5< z+e>;s#4cJ-z%&-^&!xsYx777Wt(wZY9(3(avmr|gRe4cD+a8&!LY`1^T?7x{E<=kdY9NYw>A;FtTvQ=Y&1M%lyZPl$ss1oY^Sl8we}n}Aob#6 zl4jERwnt9BlSoWb@3HxYgga(752Vu6Y)k4yk9u~Kw>cA5&LHcrvn1Y-HoIuFWg~}4 zEw4bR`mXZQIyOAzo)FYqg?$5W<;^+XX%Uz61{-L6@eP|lLH%|w?g=rFc;OvEW;^qh z&iYXGhVt(G-q<+_j}CTbPS_=K>RKN0&;dubh0NxJyDOHFF;<1k!{k#7b{|Qok9hac z;gHz}6>H6C6RnB`Tt#oaSrX0p-j-oRJ;_WvS-qS--P*8}V943RT6kou-G=A+7QPGQ z!ze^UGxtW3FC0$|(lY9^L!Lx^?Q8cny(rR`es5U;-xBhphF%_WNu|aO<+e9%6LuZq zt(0PoagJG<%hyuf;te}n+qIl_Ej;czWdc{LX^pS>77s9t*2b4s5dvP_!L^3cwlc)E!(!kGrg~FescVT zZCLeua3f4;d;Tk4iXzt}g}O@nlK3?_o91_~@UMIl?@77Qc$IAlLE95#Z=TES>2E%z zxUKpK{_HvGF;5%Q7n&vA?`{%8ohlYT_?(3A$cZSi)MvIJygXD}TS-3UwyUxGLGiJP znblO~G|*uA^|ac8E-w#}uBtg|s_~s&t>-g0X%zIZ@;o_wNMr_;{KDg^O=rg`fhDZu zFp(VKd1Edj%F zWHPl+)FGj%J1BO3bOHVfH^3d1F{)*PL&sRX`~(-Zy3&9UQX)Z;c51tvaI2E*E7!)q zcz|{vpK7bjxix(k&6=OEIBJC!9lTkUbgg?4-yE{9+pFS)$Ar@vrIf`D0Bnsed(Cf? zObt2CJ>BKOl>q8PyFO6w)+6Iz`LW%T5^R`U_NIW0r1dWv6OY=TVF?N=EfA(k(~7VBW(S;Tu5m4Lg8emDG-(mOSSs=M9Q&N8jc^Y4&9RqIsk(yO_P(mcCr}rCs%1MW1VBrn=0-oQN(Xj!k%iKV zb%ricBF3G4S1;+8lzg5PbZ|$Se$)I=PwiK=cDpHYdov2QO1_a-*dL4KUi|g&oh>(* zq$<`dQ^fat`+VW?m)?_KLn&mp^-@d=&7yGDt<=XwZZC=1scwxO2^RRI7n@g-1o8ps z)&+et_~)vr8aIF1VY1Qrq~Xe``KJrQSnAZ{CSq3yP;V*JC;mmCT6oRLSs7=GA?@6g zUooM}@tKtx(^|aKK8vbaHlUQqwE0}>j&~YlN3H#vKGm@u)xxS?n9XrOWUfCRa< z`20Fld2f&;gg7zpo{Adh+mqNntMc-D$N^yWZAZRI+u1T1zWHPxk{+?vcS1D>08>@6 zLhE@`gt1Y9mAK6Z4p|u(5I%EkfU7rKFSM=E4?VG9tI;a*@?6!ey{lzN5=Y-!$WFSe z&2dtO>^0@V4WRc#L&P%R(?@KfSblMS+N+?xUN$u3K4Ys%OmEh+tq}fnU}i>6YHM?< zlnL2gl~sF!j!Y4E;j3eIU-lfa`RsOL*Tt<%EFC0gPzoHfNWAfKFIKZN8}w~(Yi~=q z>=VNLO2|CjkxP}RkutxjV#4fWYR1KNrPYq5ha9Wl+u>ipsk*I(HS@iLnmGH9MFlTU zaFZ*KSR0px>o+pL7BbhB2EC1%PJ{67_ z#kY&#O4@P=OV#-79y_W>Gv2dxL*@G7%LksNSqgId9v;2xJ zrh8uR!F-eU$NMx@S*+sk=C~Dxr9Qn7TfWnTupuHKuQ$;gGiBcU>GF5sWx(~4IP3`f zWE;YFO*?jGwYh%C3X<>RKHC-DZ!*r;cIr}GLOno^3U4tFSSoJp%oHPiSa%nh=Zgn% z14+8v@ygy0>UgEN1bczD6wK45%M>psM)y^)IfG*>3ItX|TzV*0i%@>L(VN!zdKb8S?Qf7BhjNpziA zR}?={-eu>9JDcl*R=OP9B8N$IcCETXah9SUDhr{yrld{G;PnCWRsPD7!eOOFBTWUQ=LrA_~)mFf&!zJX!Oc-_=kT<}m|K52 z)M=G#;p;Rdb@~h5D{q^K;^fX-m5V}L%!wVC2iZ1uu401Ll}#rocTeK|7FAeBRhNdQ zCc2d^aQnQp=MpOmak60N$OgS}a;p(l9CL`o4r(e-nN}mQ?M&isv-P&d$!8|1D1I(3-z!wi zTgoo)*Mv`gC?~bm?S|@}I|m-E2yqPEvYybiD5azInexpK8?9q*$9Yy9-t%5jU8~ym zgZDx>!@ujQ=|HJnwp^wv-FdD{RtzO9SnyfB{mH_(c!jHL*$>0o-(h(eqe*ZwF6Lvu z{7rkk%PEqaA>o+f{H02tzZ@TWy&su?VNw43! z-X+rN`6llvpUms3ZiSt)JMeztB~>9{J8SPmYs&qohxdYFi!ra8KR$35Zp9oR)eFC4 zE;P31#3V)n`w$fZ|4X-|%MX`xZDM~gJyl2W;O$H25*=+1S#%|53>|LyH za@yh+;325%Gq3;J&a)?%7X%t@WXcWL*BaaR*7UEZad4I8iDt7^R_Fd`XeUo256;sAo2F!HcIQKk;h})QxEsPE5BcKc7WyerTchgKmrfRX z!x#H_%cL#B9TWAqkA4I$R^8{%do3Y*&(;WFmJ zU7Dih{t1<{($VtJRl9|&EB?|cJ)xse!;}>6mSO$o5XIx@V|AA8ZcoD88ZM?C*;{|f zZVmf94_l1OmaICt`2sTyG!$^UeTHx9YuUP!omj(r|7zpm5475|yXI=rR>>fteLI+| z)MoiGho0oEt=*J(;?VY0QzwCqw@cVm?d7Y!z0A@u#H?sCJ*ecvyhj& z-F77lO;SH^dmf?L>3i>?Z*U}Em4ZYV_CjgfvzYsRZ+1B!Uo6H6mbS<-FFL`ytqvb& zE7+)2ahv-~dz(Hs+f})z{*4|{)b=2!RZK;PWwOnO=hG7xG`JU5>bAvUbdYd_CjvtHBHgtGdlO+s^9ca^Bv3`t@VRX2_AD$Ckg36OcQRF zXD6QtGfHdw*hx~V(MV-;;ZZF#dJ-piEF+s27z4X1qi5$!o~xBnvf=uopcn7ftfsZc zy@(PuOk`4GL_n(H9(E2)VUjqRCk9kR?w)v@xO6Jm_Mx})&WGEl=GS0#)0FAq^J*o! zAClhvoTsNP*-b~rN{8Yym3g{01}Ep^^Omf=SKqvN?{Q*C4HNNAcrowIa^mf+3PRy! z*_G-|3i8a;+q;iP@~Of_$(vtFkB8yOyWt2*K)vAn9El>=D;A$CEx6b*XF@4y_6M+2 zpeW`RHoI_p(B{%(&jTHI->hmNmZjHUj<@;7w0mx3&koy!2$@cfX{sN19Y}euYJFn& z1?)+?HCkD0MRI$~uB2UWri})0bru_B;klFdwsLc!ne4YUE;t41JqfG# zZJq6%vbsdx!wYeE<~?>o4V`A3?lN%MnKQ`z=uUivQN^vzJ|C;sdQ37Qn?;lpzg})y z)_2~rUdH}zNwX;Tp0tJ78+&I=IwOQ-fl30R79O8@?Ub8IIA(6I`yHn%lARVL`%b8+ z4$8D-|MZZWxc_)vu6@VZN!HsI$*2NOV&uMxBNzIbRgy%ob_ zhwEH{J9r$!dEix9XM7n&c{S(h>nGm?el;gaX0@|QnzFD@bne`el^CO$yXC?BDJ|Qg z+y$GRoR`?ST1z^e*>;!IS@5Ovb7*RlN>BV_UC!7E_F;N#ky%1J{+iixp(dUJj93aK zzHNN>R-oN7>kykHClPnoPTIj7zc6KM(Pnlb(|s??)SMb)4!sMHU^-ntJwY5Big7xv zb1Ew`Xj;|D2kzGja*C$eS44(d&RMU~c_Y14V9_TLTz0J#uHlsx`S6{nhsA0dWZ#cG zJ?`fO50E>*X4TQLv#nl%3GOk*UkAgt=IY+u0LNXqeln3Z zv$~&Li`ZJOKkFuS)dJRA>)b_Da%Q~axwA_8zNK{BH{#}#m}zGcuckz}riDE-z_Ms> zR8-EqAMcfyGJCtvTpaUVQtajhUS%c@Yj}&6Zz;-M7MZzqv3kA7{SuW$oW#=0az2wQ zg-WG@Vb4|D`pl~Il54N7Hmsauc_ne-a!o5#j3WaBBh@Wuefb!QJIOn5;d)%A#s+5% zuD$H=VNux9bE-}1&bcYGZ+>1Fo;3Z@e&zX^n!?JK*adSbONm$XW9z;Q^L>9U!}Toj2WdafJ%oL#h|yWWwyAGxzfrAWdDTtaKl zK4`5tDpPg5>z$MNv=X0LZ0d6l%D{(D8oT@+w0?ce$DZ6pv>{1&Ok67Ix1 zH}3=IEhPJEhItCC8E=`T`N5(k?G=B4+xzZ?<4!~ ze~z6Wk9!CHTI(0rLJ4{JU?E-puc;xusR?>G?;4vt;q~iI9=kDL=z0Rr%O$vU`30X$ zDZRFyZ`(omOy@u|i6h;wtJlP;+}$|Ak|k2dea7n?U1*$T!sXqqOjq^NxLPMmk~&qI zYg0W?yK8T(6+Ea+$YyspKK?kP$+B`~t3^Pib_`!6xCs32!i@pqXfFV6PmBIR<-QW= zN8L{pt0Vap0x`Gzn#E@zh@H)0FfVfA_Iu4fjYZ+umO1LXIbVc$pY+E234u)ttcrl$ z>s92z4vT%n6cMb>=XT6;l0+9e(|CZG)$@C7t7Z7Ez@a)h)!hyuV&B5K%%)P5?Lk|C zZZSVzdXp{@OXSP0hoU-gF8s8Um(#xzjP2Vem zec#-^JqTa&Y#QJ>-FBxd7tf`XB6e^JPUgagB8iBSEps;92KG`!#mvVcPQ5yNC-GEG zTiHEDYfH+0O15}r^+ z#jxj=@x8iNHWALe!P3R67TwmhItn**0JwnzSV2O&KE8KcT+0hWH^OPD1pwiuyx=b@ zNf5Jh0{9X)8;~Es)$t@%(3!OnbY+`@?i{mGX7Yy}8T_*0a6g;kaFPq;*=px5EhO{Cp%1kI<0?*|h8v!6WnO3cCJRF2-CRrU3JiLJnj@6;L)!0kWYAc_}F{2P))3HmCrz zQ&N&gE70;`!6*eJ4^1IR{f6j4(-l&X!tjHxkbHA^Zhrnhr9g{exN|xrS`5Pq=#Xf& zG%P=#ra-TyVFfgW%cZo5OSIwFL9WtXAlFOa+ubmI5t*3=g#Y zF%;70p5;{ZeFL}&}yOY1N1*Q;*<(kTB!7vM$QokF)yr2FlIU@$Ph58$Bz z0J?xQG=MlS4L6jA22eS42g|9*9pX@$#*sUeM(z+t?hr@r5J&D1rx}2pW&m*_`VDCW zUYY@v-;bAO0HqoAgbbiGGC<=ryf96}3pouhy3XJrX+!!u*O_>Si38V{uJmQ&USptX zKp#l(?>%^7;2%h(q@YWS#9;a!JhKlkR#Vd)ERILlgu!Hr@jA@V;sk4BJ-H#p*4EqC zDGjC*tl=@3Oi6)Bn^QwFpul18fpkbpg0+peH$xyPBqb%`$OUhPKyWb32o7clB*9Z< zN=i~NLjavrLtwgJ01bufP+>p-jR2I95|TpmKpQL2!oV>g(4RvS2pK4*ou%m(h6r3A zX#s&`9LU1ZG&;{CkOK!4fLDTnBys`M!vuz>Q&9OZ0hGQl!~!jSDg|~s*w52opC{sB ze|Cf2luD(*G13LcOAGA!s2FjSK8&IE5#W%J25w!vM0^VyQM!t)inj&RTiJ!wXzFgz z3^IqzB7I0L$llljsGq})thBy9UOyjtFO_*hYM_sgcMk>44jeH0V1FDyELc{S1F-;A zS;T^k^~4biG&V*Irq}O;e}j$$+E_#G?HKIn05iP3j|87TkGK~SqG!-KBg5+mN(aLm z8ybhIM`%C19UX$H$KY6JgXbY$0AT%rEpHC;u`rQ$Y=rxUdsc5*Kvc8jaYaO$^)cI6){P6K0r)I6DY4Wr4&B zLQUBraey#0HV|&c4v7PVo3n$zHj99(TZO^3?Ly%C4nYvJTL9eLBLHsM3WKKD>5!B` zQ=BsR3aR6PD(Fa>327E2HAu5TM~Wusc!)>~(gM)+3~m;92Jd;FnSib=M5d6;;5{%R zb4V7DEJ0V!CP-F*oU?gkc>ksUtAYP&V4ND5J>J2^jt*vcFflQWCrB&fLdT%O59PVJ zhid#toR=FNgD!q3&r8#wEBr`!wzvQu5zX?Q>nlSJ4i@WC*CN*-xU66F^V5crWevQ9gsq$I@z1o(a=k7LL~ z7m_~`o;_Ozha1$8Q}{WBehvAlO4EL60y5}8GDrZ< zXh&F}71JbW2A~8KfEWj&UWV#4+Z4p`b{uAj4&WC zha`}X@3~+Iz^WRlOHU&KngK>#j}+_o@LdBC1H-`gT+krWX3-;!)6?{FBp~%20a}FL zFP9%Emqcwa#(`=G>BBZ0qZDQhmZKJg_g8<=bBFKWr!dyg(YkpE+|R*SGpDVU!+VlU zFC54^DLv}`qa%49T>nNiA9Q7Ips#!Xx90tCU2gvK`(F+GPcL=J^>No{)~we#o@&mUb6c$ zCc*<|NJBk-#+{j9xkQ&ujB zI~`#kN~7W!f*-}wkG~Ld!JqZ@tK}eeSnsS5J1fMFXm|`LJx&}5`@dK3W^7#Wnm+_P zBZkp&j1fa2Y=eIjJ0}gh85jt43kaIXXv?xmo@eHrka!Z|vQv12HN#+!I5E z`(fbuW>gFiJL|uXJ!vKt#z3e3HlVdboH7;e#i3(2<)Fg-I@BR!qY#eof3MFZ&*Y@l zI|KJf&ge@p2Dq09Vu$$Qxb7!}{m-iRk@!)%KL)txi3;~Z4Pb}u@GsW;ELiWeG9V51 znX#}B&4Y2E7-H=OpNE@q{%hFLxwIpBF2t{vPREa8_{linXT;#1vMRWjOzLOP$-hf( z>=?$0;~~PnkqY;~K{EM6Vo-T(0K{A0}VUGmu*hR z{tw3hvBN%N3G3Yw`X5Te+F{J`(3w1s3-+1EbnFQKcrgrX1Jqvs@ADGe%M0s$EbK$$ zK)=y=upBc6SjGYAACCcI=Y*6Fi8_jgwZlLxD26fnQfJmb8^gHRN5(TemhX@0e=vr> zg`W}6U>x6VhoA3DqsGGD9uL1DhB3!OXO=k}59TqD@(0Nb{)Ut_luTioK_>7wjc!5C zIr@w}b`Fez3)0wQfKl&bae7;PcTA7%?f2xucM0G)wt_KO!Ewx>F~;=BI0j=Fb4>pp zv}0R^xM4eti~+^+gE$6b81p(kwzuDti(-K9bc|?+pJEl@H+jSYuxZQV8rl8 zjp@M{#%qItIUFN~KcO9Hed*`$5A-2~pAo~K&<-Q+`9`$CK>rzqAI4w~$F%vs9s{~x zg4BP%Gy*@m?;D6=SRX?888Q6peF@_4Z->8wAH~Cn!R$|Hhq2cIzFYqT_+cDourHbY z0qroxJnrZ4Gh+Ay+F`_c%+KRT>y3qw{)89?=hJ@=KO=@ep)aBJ$c!JHfBMJpsP*3G za7|)VJJ8B;4?n{~ldJF7%jmb`-ftIvNd~ekoufG(`K(3=LNc;HBY& z(lp#q8XAD#cIf}k49zX_i`*fO+#!zKA&%T3j@%)R+#yag067CU%yUEe47>wzGU8^` z1EXFT^@I!{J!F8!X?S6ph8J=gUi5tl93*W>7}_uR<2N2~e}FaG?}KPyugQ=-OGEZs z!GBoyYY+H*ANn4?Z)X4l+7H%`17i5~zRlRIX?t)6_eu=g2Q`3WBhxSUeea+M-S?RL zX9oBGKn%a!H+*hx4d2(I!gsi+@SQK%<{X22M~2tMulJoa)0*+z9=-YO+;DFEm5eE1U9b^B(Z}2^9!Qk`!A$wUE z7$Ar5?NRg2&G!AZqnmE64eh^Anss3i!{}%6@Et+4rr!=}!SBF8eZ2*J3ujCWbl;3; z48H~goPSv(8X61fKKdpP!Z7$88NL^Z?j`!^*I?-P4X^pMxyWz~@$(UeAcTSDd(`vO z{~rc;9|GfMJcApU3k}22a!&)k4{CU!e_ny^Y3cO;tOvOMKEyWz!vG(Kp*;hB?d|R3`2X~=5a6#^o5@qn?J-bI8Ppip{-yG z!k|VcGsq!jF~}7DMr49Wap-s&>o=U^T0!Lcy}!(bhtYsPQy z4|EJe{12QL#=c(suQ89Mhw9<`bui%nx7Nep`C&*M3~vMEACmcRYYRGtANq$F%zh&V zc)cEVeHz*Z1N)L7k-(k3np#{GcDh2Q@ya0YHl*n7fl*ZPAsbU-a94MYYtA#&!c`xGIaV;yzsmrjfieTEtqB_WgZp2*NplHx=$O{M~2#i_vJ{ps-NgK zQsxKK_CBM2PP_je+Xft`(vYfXXgIUr{=PA=7a8`2EHk)Ym2QKIforz# tySWtj{oF3N9@_;i*Fv5S)9x^z=nlWP>jpp-9)52ZmLVA=i*%6g{{fxOO~wEK literal 0 HcmV?d00001 diff --git a/example/windows/runner/runner.exe.manifest b/example/windows/runner/runner.exe.manifest new file mode 100644 index 00000000..153653e8 --- /dev/null +++ b/example/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/example/windows/runner/utils.cpp b/example/windows/runner/utils.cpp new file mode 100644 index 00000000..3cb71466 --- /dev/null +++ b/example/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/example/windows/runner/utils.h b/example/windows/runner/utils.h new file mode 100644 index 00000000..3879d547 --- /dev/null +++ b/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/example/windows/runner/win32_window.cpp b/example/windows/runner/win32_window.cpp new file mode 100644 index 00000000..60608d0f --- /dev/null +++ b/example/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/example/windows/runner/win32_window.h b/example/windows/runner/win32_window.h new file mode 100644 index 00000000..e901dde6 --- /dev/null +++ b/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/posthog_flutter/lib/posthog_flutter_dart.dart b/posthog_flutter/lib/posthog_flutter_dart.dart new file mode 100644 index 00000000..66220dd0 --- /dev/null +++ b/posthog_flutter/lib/posthog_flutter_dart.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +import 'src/posthog_desktop_app_info.dart'; +import 'src/posthog_desktop_storage.dart'; +import 'src/posthog_desktop_time_zone.dart'; +import 'src/posthog_flutter_desktop.dart'; +import 'src/posthog_flutter_platform_interface.dart'; + +/// Registers the Windows and Linux implementation of the plugin, which runs +/// in Dart: there is no native PostHog SDK for these platforms. +class PosthogFlutterDart { + PosthogFlutterDart._(); + + /// Installs the implementation for the running app as the platform + /// instance. Called by the plugin registrant of Windows and Linux apps. + static void registerWith() { + final environment = Platform.environment; + PosthogFlutterPlatformInterface.instance = PosthogFlutterDesktop( + appDirectory: DesktopStorage.appDirectory( + environment, + executable: Platform.resolvedExecutable, + ), + appInfo: DesktopAppInfo.fromPlatform(), + timezone: DesktopTimeZone.read(environment), + ); + } +} diff --git a/posthog_flutter/lib/src/core/PORTING_NOTES.md b/posthog_flutter/lib/src/core/PORTING_NOTES.md new file mode 100644 index 00000000..31afba0a --- /dev/null +++ b/posthog_flutter/lib/src/core/PORTING_NOTES.md @@ -0,0 +1,73 @@ +# Porting notes + +The client behind the Windows and Linux implementation of posthog_flutter +(this directory) is a hand-port of `@posthog/core`. The first port was taken +from the `posthog-core` package of +[posthog-js-lite](https://github.com/PostHog/posthog-js-lite) at commit +`1e2e226c59f3c3a63f780d9b61f6d06304f86903`; that repository is archived and +its core now lives in +[posthog-js `packages/core`](https://github.com/PostHog/posthog-js/tree/main/packages/core), +which later behavior (person profiles, `getFeatureFlagResult`) follows. + +Only what posthog_flutter calls is ported. For example, there is no +`shutdown()`, `createPersonProfile()`, `onFeatureFlag()`, `resetSessionId()` +or `getFeatureFlagPayload()`, and `reset()` keeps no properties but the device +id. + +Deliberate deviations from `@posthog/core`: + +| Behavior | `@posthog/core` | This port | Why | +| --- | --- | --- | --- | +| Requests and persistence | Each platform implements `fetch()`, `getPersistedProperty()` and `setPersistedProperty()` | The client sends requests through `dart:io`'s `HttpClient`, honors `PostHogConfig.compression`, and keeps its state and queue in `FileStorage` | Windows and Linux share one implementation | +| Options | `PostHogCoreOptions`, including request timeouts, retries, session expiration, `disabled` and `disableGeoip` | posthog_flutter's `PostHogConfig`. Requests time out after 10 s, a batch is retried 3 times 3 s apart, and a session ends after 30 minutes without events or after 24 hours | posthog_flutter exposes no other options | +| Batch request retries | A fixed delay between attempts, with no pause between retry cycles | Without `Retry-After`, three retries remain 3 s apart. A positive `Retry-After` pauses the whole queue, which manual flush and flushAt do not bypass; close cancels the timer | The server pause matches Android/iOS; behavior without the header is unchanged | +| `before_send` | Runs on every event, SDK-internal ones included | Not in the client: the Windows and Linux implementation runs `PostHogConfig.beforeSend` on the events the app captures, before they reach the client | SDK-internal events and properties stay out of the callbacks, as on the other platforms of posthog_flutter | +| Quota-limited `/flags` response | Unsets all cached flags | Keeps serving cached flags; `quota_limited` is reported only when there is no cached value | Parity with posthog-android / posthog-ios | +| Person properties on `identify()` / `capture()` | Not fed into flag evaluation | `$set` / `$set_once` merge into the persisted person properties for flags (`$set` wins) | Parity with the mobile SDKs | +| Default person properties for flags | Not supported | Sent with every `/flags` request from the `getDefaultPersonPropertiesForFlags()` override; explicitly set properties win | Parity with the mobile SDKs | +| `getFeatureFlag` of an unknown key | `false` once flags are loaded | `null` | posthog_flutter documents `null` for a missing flag; all platforms must answer alike | +| Stopping the client | `shutdown()` sends the queue first; later calls are still enqueued | `close()` sends nothing: requests in flight are aborted, queued events stay stored for the next client, and later calls are ignored with a debug log | `Posthog().close()` must not wait for the network; parity with posthog-ios `close()` | +| Async init guards | `_initPromise` checked in every accessor | None | Dart construction is synchronous; the object is not observable before the constructor returns | +| Event listeners | `on(event, listener)` for every client event, and a `'*'` wildcard | Only `onFeatureFlags()`; debug mode logs the queued, delivered and failed events | posthog_flutter exposes no other listener | +| Flags known at startup | Listeners wait for the next `/flags` response | Flags that are bootstrapped or cached by an earlier run are announced right after the client is created, unless the outcome of a `/flags` request is announced first | The on-feature-flags spec | +| `identify()` | An empty id falls back to the current one; a different id always switches the user | An empty id is ignored; a different id while identified is ignored until `reset()`; the current id sends `$set` instead of `$identify`, and an identified user's new properties do not reload flags | Parity with posthog-ios / posthog-android | +| Calls while opted out | Identity, groups and person properties still change and flags reload; only the event is dropped | `identify`, `alias`, `group`, `setPersonProperties` and `capture` return before changing anything | Parity with posthog-ios and the consent spec | +| `$set` / `$set_once` in `capture()` | Person processing stays as it was | Turns person processing on, so this and later events are person-processed | Parity with posthog-ios / posthog-android | +| `$groups` in `capture()` | Registered as groups for all later events, reloading flags | Sent with that event only | Parity with posthog-ios / posthog-android | +| `group()` | `$groupidentify` only with group properties, which do not feed flags | `$groupidentify` on every call; group properties also feed flag evaluation | Parity with posthog-ios / posthog-android | +| An empty alias, group type or group key | Sent as it is | The call is ignored, with a debug warning | The alias and group specs | +| `$identify`, `$create_alias`, `$groupidentify` | No `$process_person_profile` / `$is_identified` | Both, like every other event | Parity with posthog-ios | +| Opt-out across `reset()` | Persisted opt-out cleared; the configured default applies at once | Persisted opt-out cleared; this client keeps the decision until it is recreated | Parity with posthog-ios / posthog-android, which keep opt-out in memory | +| Consent while the storage cannot be read | No such state | Unknown consent fails closed; `optIn()` / `optOut()` still apply to the running client, and win over the stored decision once it can be read | Tracking without known consent would be worse than dropping events | +| Two instances of the app at once | Left to each platform's storage | The first instance locks the storage. The second keeps its identity and consent changes in memory only, so a `disable()` made there is lost on restart; its events are still stored and sent | The second instance must not overwrite the identity and consent of the first | +| Session | Persisted and resumed after a restart within the inactivity timeout | Kept in memory; every client starts a new session | Parity with posthog-ios | +| `getSessionId()` | Marks the session active and starts a new one after the timeout | Read-only: only events extend or rotate the session | Parity with posthog-ios `getSessionId()` | +| `$timezone` | Not sent | Sent with every event when the host provides it through `getTimezone()` | Parity with posthog-ios | +| Session debug properties | Not sent | `$sdk_debug_session_start`, `$sdk_debug_current_session_duration`, `$sdk_debug_pending_queue_size` on every event | Parity with posthog-ios / posthog-android | +| `/flags` request | No device id; no retries | `$device_id` (the first anonymous id, kept by `reset()`), `timezone` when a platform overrides `getTimezone()`; one retry after 300 ms on network errors and HTTP 502 / 504 | Parity with posthog-ios and the http-client spec | +| Flag payload that is not valid JSON | Returned as the raw string | `null`, with a debug warning | The get-feature-flag-payload spec | +| `$feature_flag_called` deduplication | Per flag, cleared by every flags reload | Per flag and value, cleared by `reset()` | Parity with posthog-ios / posthog-android | +| `$active_feature_flags` | Every loaded flag, disabled ones included | Enabled flags only; left out when there are none | Parity with posthog-ios / posthog-android | +| Null-valued event properties | Sent as JSON `null` | Left out at every level, `$set` / `$set_once` included; array elements keep their positions. Kept only in the `$feature_flag_response` of a flag that has no value | The capture spec | +| Property values JSON cannot represent | `JSON.stringify` rules | Before they reach the client, the values the platform channels cannot carry become their `toString()`, as on the other platforms of posthog_flutter; a `DateTime` in UTC becomes `2025-01-01 00:00:00.000Z`. The client sends NaN and infinite numbers as `"NaN"` / `"Infinity"`, with a debug warning. Applies to events, exception steps, super properties and flag evaluation properties | posthog_flutter normalizes property values alike on every platform | +| Event queue storage | One persisted property, rewritten with every event | A queue of its own in the storage; `FileStorage` writes one file per event and deletes delivered events by id | Parity with posthog-ios: queueing an event does not rewrite the others | +| `maxQueueSize` / `maxBatchSize` | Raised to at least `flushAt` | Applied as configured. A queue capped below `flushAt` is sent by the periodic flush | Parity with posthog-ios / posthog-android; the retry-queue and event-batcher specs | +| HTTP 3xx from `/batch/` | Treated as delivered | Kept queued and retried like a transient failure | Parity with posthog-ios / posthog-android: a POST redirect is not followed, so the events never reached ingestion | +| Queue left by a previous run | Sent with the first flush after a new event | Sent with the first periodic flush after start | Parity with posthog-ios | +| Bootstrap | Identity seeded only when nothing is persisted; persisted flags win over bootstrapped ones; `$used_bootstrap_value` on every `$feature_flag_called` | An identified bootstrap is also reconciled with a stored user; bootstrapped flags replace persisted ones until the first complete `/flags` response; bootstrap properties only for bootstrapped flags | Parity with posthog-ios and the bootstrap spec | + +## Desktop and mobile platform contracts + +The Flutter public API is shared, but Android and iOS differ in behavior. Regular super properties use `event > session > registered`: this is the contract of `registerForSession`, Android, and the Behavior section of the [register spec](https://github.com/PostHog/sdk-specs/blob/main/openspec/specs/register/spec.md). The [capture spec](https://github.com/PostHog/sdk-specs/blob/main/openspec/specs/capture/spec.md) describes the reverse registered priority used by iOS. Desktop keeps its documented order. The SDK still generates OS/app context, SDK metadata, flags, and person-processing hints. A non-empty caller `$session_id` applies to its event only; the internal session continues to track activity. + +An invalid registered `$groups` remains a custom property, but is not interpreted as membership: only string pairs are used for person processing and flags. Valid maps from previous runs continue to work without migration. The new maxQueueSize applies to a persisted queue before its first send. + +Flags responses support rich `flags` and legacy `featureFlags`/`featureFlagPayloads`. When the rich field is present, it determines the result, even when the map is empty. The same cache, quota, and partial-response rules apply to both formats. + +Bootstrap persists, `getSessionId()` remains read-only, and disable does not delete exception steps that have already been recorded. This matches iOS; exception steps also follow their own spec. `sendFeatureFlagEvents=false` disables exposure events but preserves flags context in capture. An invalid JSON payload returns null according to the payload spec, although mobile implementations may return the original string. + +The Windows time zone is read during plugin registration through the system ICU from System32. A `TZ` value with an IANA name takes precedence. ICU is included with Windows; the combined icu.dll in use is available from Windows 10 1903. If the library is unavailable or the zone is unknown, the time zone is omitted and the SDK continues to run. This is best effort, with no custom Windows/IANA table or observer for system time zone changes. Sources: [Microsoft ICU](https://learn.microsoft.com/windows/win32/intl/international-components-for-unicode--icu-), [ICU Calendar API](https://unicode-org.github.io/icu-docs/apidoc/dev/icu4c/ucal_8h.html). + +Structured logs, session replay, surveys, push, and native crash hooks are not yet implemented on desktop. A shared Flutter method does not imply transport support on every platform. The shared facade runs beforeSend for logs, but desktop captureLog does not send entries. + +The desktop adapter also retains three limitations of the current Flutter API on mobile. Consent and the active client are resolved after an asynchronous `beforeSend`, so enabling analytics or replacing the client while the callback is pending can change where the event is sent. Reading a flag while opted out consumes its exposure deduplication entry, as in the native mobile SDKs. Renaming a screen or exception event in `beforeSend` routes it through capture and runs the callback again, as in the Flutter IO adapter. These behaviors need a coordinated cross-platform change rather than a desktop-only contract. diff --git a/posthog_flutter/lib/src/core/event_emitter.dart b/posthog_flutter/lib/src/core/event_emitter.dart new file mode 100644 index 00000000..1f4310fc --- /dev/null +++ b/posthog_flutter/lib/src/core/event_emitter.dart @@ -0,0 +1,51 @@ +/// A simple event emitter supporting named events and catch-all listeners. +class SimpleEventEmitter { + final Map> _listeners = {}; + final List _anyListeners = []; + final void Function(Object error) _onListenerError; + + SimpleEventEmitter({required void Function(Object error) onListenerError}) + : _onListenerError = onListenerError; + + /// Registers a listener for [event]. Returns an unsubscribe function. + void Function() on(String event, void Function(Object? payload) listener) { + final listeners = _listeners.putIfAbsent(event, () => []); + listeners.add(listener); + + return () { + listeners.remove(listener); + }; + } + + /// Registers a listener for every event. Returns an unsubscribe function. + void Function() onAny(void Function(String event, Object? payload) listener) { + _anyListeners.add(listener); + + return () { + _anyListeners.remove(listener); + }; + } + + /// Emits an event with a payload. A listener that throws is reported via + /// the error hook and does not stop the other listeners. + void emit(String event, Object? payload) { + final listeners = _listeners[event]; + if (listeners != null) { + for (final listener in List.of(listeners)) { + _guard(() => listener(payload)); + } + } + + for (final listener in List.of(_anyListeners)) { + _guard(() => listener(event, payload)); + } + } + + void _guard(void Function() invoke) { + try { + invoke(); + } catch (e) { + _onListenerError(e); + } + } +} diff --git a/posthog_flutter/lib/src/core/feature_flag_utils.dart b/posthog_flutter/lib/src/core/feature_flag_utils.dart new file mode 100644 index 00000000..315340ed --- /dev/null +++ b/posthog_flutter/lib/src/core/feature_flag_utils.dart @@ -0,0 +1,125 @@ +import 'feature_flags.dart'; + +/// Converts rich and legacy responses to [PostHogFlagsResponse]. +PostHogFlagsResponse parseFlagsResponse( + Map response, { + required void Function(String key, Object error) onMalformedFlag, +}) { + final flags = response.containsKey('flags') + ? PostHogFeatureFlagDetail.parseAll( + response['flags'] as Map? ?? {}, + onMalformed: onMalformedFlag, + ) + : _parseLegacyFlags(response, onMalformedFlag: onMalformedFlag); + + return PostHogFlagsResponse( + flags: flags, + errorsWhileComputingFlags: + response['errorsWhileComputingFlags'] as bool? ?? false, + quotaLimited: (response['quotaLimited'] as List?) + ?.map((e) => e as String) + .toList(), + requestId: response['requestId'] as String?, + evaluatedAt: response['evaluatedAt'] as int?, + // Anything but an explicit true keeps full events. + minimalFlagCalledEvents: response['minimalFlagCalledEvents'] == true, + ); +} + +Map _parseLegacyFlags( + Map response, { + required void Function(String key, Object error) onMalformedFlag, +}) { + final values = response['featureFlags'] as Map? ?? {}; + final payloads = response['featureFlagPayloads'] as Map?; + final flags = {}; + for (final entry in values.entries) { + final value = entry.value; + if (value is! bool && value is! String) { + onMalformedFlag(entry.key, + const FormatException('Feature flag value must be bool or String')); + continue; + } + + final rawPayload = payloads?[entry.key]; + if (rawPayload != null && rawPayload is! String) { + onMalformedFlag( + entry.key, + const FormatException( + 'Feature flag payload must be serialized JSON')); + } + flags[entry.key] = PostHogFeatureFlagDetail( + key: entry.key, + enabled: value is String || value == true, + variant: value is String ? value : null, + metadata: rawPayload is String + ? PostHogFeatureFlagMetadata(payload: rawPayload) + : null, + ); + } + return flags; +} + +/// Properties a minimal `$feature_flag_called` event keeps. The server asks +/// for minimal events per project, and they are only sent for flags that are +/// verifiably not linked to an experiment. Everything else - super +/// properties, `$feature/`, `$active_feature_flags`, debug properties - +/// is dropped. +const minimalFeatureFlagCalledProperties = { + r'$feature_flag', + r'$feature_flag_response', + r'$feature_flag_has_experiment', + r'$feature_flag_id', + r'$feature_flag_version', + r'$feature_flag_reason', + r'$feature_flag_request_id', + r'$feature_flag_evaluated_at', + r'$feature_flag_error', + r'$groups', + r'$process_person_profile', + r'$geoip_disable', + r'$session_id', + r'$window_id', + r'$lib', + r'$lib_version', + r'$device_id', + r'$os_name', + r'$os_version', + r'$app_version', + // A minimal event can be the first event of a session, which is where the + // server reads the session's campaign attribution from. + r'$referring_domain', + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_content', + 'utm_term', + 'gad_source', + 'mc_cid', + 'gclid', + 'gclsrc', + 'dclid', + 'gbraid', + 'wbraid', + 'fbclid', + 'msclkid', + 'twclid', + 'li_fat_id', + 'igshid', + 'ttclid', + 'rdt_cid', + 'epik', + 'qclid', + 'sccid', + 'irclid', + '_kx', +}; + +/// Get the value from a [PostHogFeatureFlagDetail]. +/// +/// Returns the variant string if present, the enabled bool otherwise. +/// Returns null if the detail is null. +PostHogFeatureFlagValue? getFeatureFlagValue(PostHogFeatureFlagDetail? detail) { + if (detail == null) return null; + return detail.variant ?? detail.enabled; +} diff --git a/posthog_flutter/lib/src/core/feature_flags.dart b/posthog_flutter/lib/src/core/feature_flags.dart new file mode 100644 index 00000000..e8fe6ea8 --- /dev/null +++ b/posthog_flutter/lib/src/core/feature_flags.dart @@ -0,0 +1,302 @@ +import 'dart:convert' show jsonDecode; + +/// Feature flag value — either a [bool] or a [String] variant. +/// +/// Dart has no union types, so this is typed as [Object]. +/// In practice, values are always `bool` or `String`. +typedef PostHogFeatureFlagValue = Object; + +/// Decodes a flag payload, which is stored as serialized JSON. +/// +/// A payload that is not valid JSON (an empty string included) is no +/// payload: null is returned and the error goes to [onMalformed]. +Object? parsePayload(String serialized, + {required void Function(FormatException error) onMalformed}) { + try { + return jsonDecode(serialized); + } on FormatException catch (e) { + onMalformed(e); + return null; + } +} + +/// Feature flag detail from the v2 API. +class PostHogFeatureFlagDetail { + final String key; + final bool enabled; + final String? variant; + final PostHogEvaluationReason? reason; + final PostHogFeatureFlagMetadata? metadata; + final bool? failed; + + const PostHogFeatureFlagDetail({ + required this.key, + required this.enabled, + this.variant, + this.reason, + this.metadata, + this.failed, + }); + + Map toJson() => { + 'key': key, + 'enabled': enabled, + if (variant != null) 'variant': variant, + if (reason != null) 'reason': reason!.toJson(), + if (metadata != null) 'metadata': metadata!.toJson(), + if (failed != null) 'failed': failed, + }; + + factory PostHogFeatureFlagDetail.fromJson(Map json) { + return PostHogFeatureFlagDetail( + key: json['key'] as String, + enabled: json['enabled'] as bool, + variant: json['variant'] as String?, + reason: json['reason'] != null + ? PostHogEvaluationReason.fromJson( + json['reason'] as Map) + : null, + metadata: json['metadata'] != null + ? PostHogFeatureFlagMetadata.fromJson( + json['metadata'] as Map) + : null, + failed: json['failed'] as bool?, + ); + } + + /// Parses a raw flags map, skipping entries whose shape is not understood + /// (reported via [onMalformed]) so one bad flag cannot take down the rest. + static Map parseAll( + Map raw, { + required void Function(String key, Object error) onMalformed, + }) { + final flags = {}; + for (final entry in raw.entries) { + try { + flags[entry.key] = PostHogFeatureFlagDetail.fromJson( + (entry.value as Map).cast()); + } catch (e) { + onMalformed(entry.key, e); + } + } + return flags; + } +} + +/// Metadata for a feature flag. +class PostHogFeatureFlagMetadata { + final int? id; + final int? version; + final String? payload; + + /// Whether the flag is linked to an experiment, or null when the server + /// did not say. + final bool? hasExperiment; + + const PostHogFeatureFlagMetadata({ + this.id, + this.version, + this.payload, + this.hasExperiment, + }); + + Map toJson() => { + 'id': id, + 'version': version, + 'payload': payload, + 'has_experiment': hasExperiment, + }; + + factory PostHogFeatureFlagMetadata.fromJson(Map json) { + final hasExperiment = json['has_experiment']; + return PostHogFeatureFlagMetadata( + id: json['id'] as int?, + version: json['version'] as int?, + payload: json['payload'] as String?, + // An unexpected shape reads as unknown rather than rejecting the flag. + hasExperiment: hasExperiment is bool ? hasExperiment : null, + ); + } +} + +/// Evaluation reason for a feature flag. +class PostHogEvaluationReason { + final String? code; + final String? description; + + const PostHogEvaluationReason({this.code, this.description}); + + Map toJson() => { + 'code': code, + 'description': description, + }; + + factory PostHogEvaluationReason.fromJson(Map json) { + return PostHogEvaluationReason( + code: json['code'] as String?, + description: json['description'] as String?, + ); + } +} + +/// Feature flags response from the server (v2 format). +class PostHogFlagsResponse { + /// The v2 flags map — the source of truth. + final Map flags; + final bool errorsWhileComputingFlags; + final List? quotaLimited; + final String? requestId; + final int? evaluatedAt; + + /// Whether the server asks for minimal `$feature_flag_called` events for + /// flags that are not linked to an experiment. + final bool minimalFlagCalledEvents; + + const PostHogFlagsResponse({ + this.flags = const {}, + this.errorsWhileComputingFlags = false, + this.quotaLimited, + this.requestId, + this.evaluatedAt, + this.minimalFlagCalledEvents = false, + }); + + /// Derived flag values (key → `bool` | `String` variant) for convenience. + Map get featureFlags { + final result = {}; + for (final entry in flags.entries) { + result[entry.key] = entry.value.variant ?? entry.value.enabled; + } + return result; + } +} + +/// Feature identifiers the server reports in +/// [PostHogFlagsResponse.quotaLimited]. +abstract final class QuotaLimitedFeature { + static const String featureFlags = 'feature_flags'; +} + +/// Error type for the `$feature_flag_error` analytics property. +enum FeatureFlagErrorType { + errorsWhileComputing('errors_while_computing_flags'), + flagMissing('flag_missing'), + quotaLimited('quota_limited'), + timeout('timeout'), + connectionError('connection_error'), + unknownError('unknown_error'); + + final String value; + const FeatureFlagErrorType(this.value); + + /// Returns an API error string with the given HTTP status code. + static String apiError(int status) => 'api_error_$status'; +} + +/// Type of error that occurred during a feature flag request. +enum FeatureFlagRequestErrorType { + timeout, + connectionError, + apiError, + unknownError, +} + +/// Represents an error that occurred during a feature flag request. +class FeatureFlagRequestError { + final FeatureFlagRequestErrorType type; + final int? statusCode; + + const FeatureFlagRequestError({required this.type, this.statusCode}); + + Map toJson() => { + 'type': type.name, + if (statusCode != null) 'statusCode': statusCode, + }; + + factory FeatureFlagRequestError.fromJson(Map json) { + return FeatureFlagRequestError( + type: FeatureFlagRequestErrorType.values + .firstWhere((e) => e.name == json['type'] as String), + statusCode: json['statusCode'] as int?, + ); + } +} + +/// Result type for flag fetching operations. +sealed class GetFlagsResult {} + +class GetFlagsSuccess extends GetFlagsResult { + final PostHogFlagsResponse response; + GetFlagsSuccess(this.response); +} + +class GetFlagsFailure extends GetFlagsResult { + final FeatureFlagRequestError error; + GetFlagsFailure(this.error); +} + +/// Flags storage format for persistence. +class PostHogFlagsStorageFormat { + final Map flags; + final String? requestId; + final int? evaluatedAt; + final bool? errorsWhileComputingFlags; + final List? quotaLimited; + final FeatureFlagRequestError? requestError; + final bool? minimalFlagCalledEvents; + + const PostHogFlagsStorageFormat({ + required this.flags, + this.requestId, + this.evaluatedAt, + this.errorsWhileComputingFlags, + this.quotaLimited, + this.requestError, + this.minimalFlagCalledEvents, + }); + + /// The stored state viewed as a flags response ([requestError] is the + /// only field without a response counterpart). + PostHogFlagsResponse toResponse() => PostHogFlagsResponse( + flags: flags, + errorsWhileComputingFlags: errorsWhileComputingFlags ?? false, + quotaLimited: quotaLimited, + requestId: requestId, + evaluatedAt: evaluatedAt, + minimalFlagCalledEvents: minimalFlagCalledEvents ?? false, + ); + + Map toJson() => { + 'flags': flags.map((k, v) => MapEntry(k, v.toJson())), + if (requestId != null) 'requestId': requestId, + if (evaluatedAt != null) 'evaluatedAt': evaluatedAt, + if (errorsWhileComputingFlags != null) + 'errorsWhileComputingFlags': errorsWhileComputingFlags, + if (quotaLimited != null) 'quotaLimited': quotaLimited, + if (requestError != null) 'requestError': requestError!.toJson(), + if (minimalFlagCalledEvents != null) + 'minimalFlagCalledEvents': minimalFlagCalledEvents, + }; + + factory PostHogFlagsStorageFormat.fromJson( + Map json, { + required void Function(String key, Object error) onMalformedFlag, + }) { + final flagsRaw = json['flags'] as Map? ?? {}; + return PostHogFlagsStorageFormat( + flags: PostHogFeatureFlagDetail.parseAll(flagsRaw, + onMalformed: onMalformedFlag), + requestId: json['requestId'] as String?, + evaluatedAt: json['evaluatedAt'] as int?, + errorsWhileComputingFlags: json['errorsWhileComputingFlags'] as bool?, + quotaLimited: (json['quotaLimited'] as List?) + ?.map((e) => e as String) + .toList(), + requestError: json['requestError'] != null + ? FeatureFlagRequestError.fromJson( + json['requestError'] as Map) + : null, + minimalFlagCalledEvents: json['minimalFlagCalledEvents'] as bool?, + ); + } +} diff --git a/posthog_flutter/lib/src/core/file_storage.dart b/posthog_flutter/lib/src/core/file_storage.dart new file mode 100644 index 00000000..b660c127 --- /dev/null +++ b/posthog_flutter/lib/src/core/file_storage.dart @@ -0,0 +1,422 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'logger.dart'; +import 'persistence.dart'; +import 'uuid.dart'; + +/// File-based storage. +/// +/// The persisted properties (identity, feature flags, consent, super +/// properties) live in one JSON snapshot, `posthog_data.json`. Every queued +/// event is a file of its own in `posthog_queue/`, named after a UUIDv7 so +/// that the names sort in queueing order: queueing an event writes one small +/// file and leaves the others alone. +/// +/// One storage at a time writes the snapshot of a directory: the first to +/// use it locks `posthog.lock` until it is closed. Another storage on the +/// same directory, such as a second instance of the app, reads the snapshot +/// once and keeps its own changes to it in memory, so it cannot overwrite the +/// identity and consent of the first. Those changes, an opt-out included, +/// do not survive a restart. Its events still go to queue files, so none is +/// lost. A client handles only the events it queued itself and, when it +/// holds the lock, those it found when it started: it deletes them once they +/// are sent, or when the queue is full. +/// +/// The lock is an operating system file lock: on network file systems it +/// may not hold, and within one process it only tells apart storages of the +/// same isolate. +/// +/// Storage never throws into the host app. While the snapshot cannot be +/// read, the store reports [isDegraded] and keeps writes in memory only, so a +/// transient failure never replaces good persisted data; once the file can +/// be read again, its content wins. Files are replaced through a temporary +/// file and a rename, without syncing them to disk: a crash can lose the +/// latest writes but never leaves a file half-written. +class FileStorage { + FileStorage(this._directory); + + static const _dataFileName = 'posthog_data.json'; + static const _lockFileName = 'posthog.lock'; + static const _queueDirectoryName = 'posthog_queue'; + + /// The lock files held by storages of this isolate. A POSIX file lock + /// belongs to the whole process and is released as soon as the process + /// closes any handle to the file, so a second storage of the same directory + /// must not even open it. + static final Set _heldLockPaths = {}; + + final String _directory; + Map? _cache; + + /// Values set while the snapshot cannot be read. They are never written + /// over the file, whose content is unknown, and give way to it once it can + /// be read. + final Map _unpersisted = {}; + + // Losing the whole snapshot (ids, consent) matters enough to log outside + // debug mode, so this does not go through [logger]. + static final CoreLogger _resetLogger = CoreLogger(_logAlways); + static void _logAlways(void Function() log) => log(); + + /// Receives what the storage reports, write failures included, since + /// nothing is thrown into the host app. A PostHog client created with this + /// storage attaches its own logger, so reports show up in its debug output. + CoreLogger? logger; + + _Role? _role; + RandomAccessFile? _lock; + String? _lockPath; + FileEventQueue? _queue; + + String _pathOf(String name) => '$_directory${Platform.pathSeparator}$name'; + + String get _dataFilePath => _pathOf(_dataFileName); + + /// Whether the snapshot is currently unreadable. + /// + /// While degraded, persisted state (including consent) is unknown - + /// consumers should treat it conservatively, e.g. consent checks fail + /// closed. + bool get isDegraded => _readAll() == null; + + /// The events waiting to be sent. + FileEventQueue get queue => + _queue ??= FileEventQueue._(_pathOf(_queueDirectoryName), () => logger, + includeExisting: _open() == _Role.primary); + + /// Decides, on first use, whether this storage writes the snapshot. + _Role _open() { + final role = _role; + if (role != null) return role; + if (_acquireLock()) return _role = _Role.primary; + logger?.info('Another PostHog client uses $_directory: changes to the ' + 'stored identity and consent stay in memory; events are queued there ' + 'as usual.'); + return _role = _Role.secondary; + } + + /// Locks the directory. Returns false when another storage holds the + /// lock, true otherwise: also when locking is not possible, which leaves + /// the directory to this storage as if it had no lock. + bool _acquireLock() { + final String lockPath; + try { + final directory = Directory(_directory)..createSync(recursive: true); + // Resolved, so that two spellings of one directory share their key. + lockPath = '${directory.resolveSymbolicLinksSync()}' + '${Platform.pathSeparator}$_lockFileName'; + } on FileSystemException catch (e) { + logger?.warn('Cannot lock the PostHog storage directory:', e); + return true; + } + if (_heldLockPaths.contains(lockPath)) return false; + + RandomAccessFile? lock; + try { + lock = File(lockPath).openSync(mode: FileMode.append); + lock.lockSync(FileLock.exclusive); + } on FileSystemException catch (e) { + // Only a lock refused on an opened file can mean another holder. + final refused = lock != null && _isLockedElsewhere(e); + lock?.closeSync(); + lock = null; + if (refused) return false; + logger?.warn('Cannot lock the PostHog storage directory:', e); + } + _lock = lock; + _lockPath = lockPath; + _heldLockPaths.add(lockPath); + return true; + } + + /// Whether a failed attempt to take the lock means that another process + /// holds it, rather than a file system without file locks. + static bool _isLockedElsewhere(FileSystemException error) { + final code = error.osError?.errorCode; + if (Platform.isWindows) return code == 33; // ERROR_LOCK_VIOLATION + return code == 11 || code == 13; // EAGAIN or EACCES on Linux + } + + /// Releases the directory lock, so that another storage can write the + /// snapshot; this one stops writing it. + void close() { + _role = _Role.secondary; + final lockPath = _lockPath; + if (lockPath != null) _heldLockPaths.remove(lockPath); + _lockPath = null; + try { + // Closing the file releases the lock. + _lock?.closeSync(); + } on FileSystemException catch (e) { + logger?.warn('Failed to release the PostHog storage lock:', e); + } + _lock = null; + } + + /// Returns the store, or null while the disk is unreadable. + Map? _readAll() { + if (_cache != null) return _cache; + final snapshot = _cache = _readSnapshot(); + if (snapshot != null && _unpersisted.isNotEmpty) { + logger?.warn('The PostHog storage file can be read again; values set ' + 'while it could not be read give way to the stored ones.'); + _unpersisted.clear(); + } + return snapshot; + } + + Map? _readSnapshot() { + final List bytes; + try { + bytes = File(_dataFilePath).readAsBytesSync(); + } on PathNotFoundException { + // No file yet. Detected via the read exception, not existsSync(), + // which also reports false on access-denied. + return {}; + } catch (_) { + // Transient IO failure: the on-disk state is unknown. + return null; + } + + try { + return jsonDecode(utf8.decode(bytes)) as Map; + } catch (e) { + // Corrupt content (torn write, foreign data) will not heal on retry, + // so the store resets instead of staying degraded forever. + _resetLogger.warn('Resetting unreadable posthog store:', e); + return {}; + } + } + + /// Returns the value stored under [key], or null when the key is absent + /// or the stored value is not a [T]. + T? getProperty(PostHogPersistedProperty key) { + final value = (_readAll() ?? _unpersisted)[key.key]; + return value is T ? value : null; + } + + /// Stores [value] under [key]; null removes the entry. A value that cannot + /// be JSON-encoded is dropped. + void setProperty(PostHogPersistedProperty key, T? value) { + final data = _readAll(); + if (data == null) { + // Unknown disk state: the file must not be overwritten. Consent is + // protected separately - consumers fail closed on isDegraded. + logger?.warn('The PostHog storage file cannot be read, keeping ' + '"${key.key}" in memory only.'); + _put(_unpersisted, key.key, value); + return; + } + + final hadKey = data.containsKey(key.key); + final previous = data[key.key]; + _put(data, key.key, value); + if (_open() != _Role.primary) return; + + final String encoded; + try { + encoded = jsonEncode(data); + } catch (e) { + // A non-encodable value would fail every snapshot write from now on; + // drop it and restore the cache. + logger?.warn( + 'Dropping a value for "${key.key}" that cannot be ' + 'JSON-encoded:', + e); + if (hadKey) { + data[key.key] = previous; + } else { + data.remove(key.key); + } + return; + } + _writeSnapshot(encoded); + } + + static void _put(Map data, String key, Object? value) { + if (value == null) { + data.remove(key); + } else { + data[key] = value; + } + } + + void _writeSnapshot(String encoded) { + try { + _writeAtomically(File(_dataFilePath), encoded); + } catch (e) { + // Best effort: the cache keeps the new value, the next successful + // write persists the whole snapshot. + logger?.warn( + 'Failed to persist the PostHog state; it is kept in ' + 'memory until the next successful write:', + e); + } + } +} + +enum _Role { + /// Holds the directory lock: writes the snapshot, and sends the events + /// found in the directory as well as its own. + primary, + + /// Another storage holds the lock, or this one released it: keeps + /// snapshot changes in memory, and sends only its own events. + secondary, +} + +/// An event in a [FileEventQueue] and the id the queue keeps it under. +typedef PostHogQueuedEvent = ({String id, Map event}); + +/// The first-in, first-out queue of a [FileStorage]: the events waiting to be +/// sent, one JSON file per event, named after the event's queue id. The order +/// is kept in memory, read once from the directory. +/// +/// Events are JSON-encodable maps; the client makes them so before adding +/// them. The client removes an event by its id once it is delivered, so an +/// event added while a batch is in flight is never removed with it. +class FileEventQueue { + FileEventQueue._(this._directory, this._logger, + {required bool includeExisting}) + : _loadedIds = includeExisting ? null : []; + + static const _extension = '.json'; + + final String _directory; + final CoreLogger? Function() _logger; + + /// The queue, oldest first: the events found in the directory, read on + /// first use, then the ones added here. + List? _loadedIds; + + /// Events whose file could not be written, so they can still be sent by + /// this client. + final Map> _unwritten = {}; + + List get _ids => _loadedIds ??= _load(); + + String _pathOf(String id) => + '$_directory${Platform.pathSeparator}$id$_extension'; + + List _load() { + try { + return [ + for (final entity in Directory(_directory).listSync()) + if (entity is File && entity.path.endsWith(_extension)) _idOf(entity), + ]..sort(); + } on PathNotFoundException { + return []; + } on FileSystemException catch (e) { + _logger()?.warn( + 'Could not list the queued events; they stay on disk for a ' + 'later run:', + e); + return []; + } + } + + static String _idOf(File file) { + final name = file.uri.pathSegments.last; + return name.substring(0, name.length - _extension.length); + } + + /// The number of queued events. + int get length => _ids.length; + + /// Adds [event] at the end of the queue. + void add(Map event) { + // Loaded before the new file exists, which would otherwise be listed + // on top of being added. + final ids = _ids; + final id = generateUuidV7(); + ids.add(id); + try { + _writeAtomically(File(_pathOf(id)), jsonEncode(event)); + } catch (e) { + _unwritten[id] = event; + _logger()?.warn( + 'Failed to persist a queued event; it is kept in memory until it ' + 'is sent:', + e); + } + } + + /// Returns up to [count] events from the front of the queue, oldest + /// first. + List peek(int count) { + final events = []; + final gone = {}; + for (final id in _ids) { + if (events.length >= count) break; + final event = _unwritten[id] ?? _read(id, gone); + if (event != null) events.add((id: id, event: event)); + } + if (gone.isNotEmpty) _ids.removeWhere(gone.contains); + return events; + } + + /// Reads the event queued as [id], or returns null. An event that is gone + /// for good (sent by another client, or unreadable and so deleted) is also + /// added to [gone]; one that cannot be read right now stays queued. + Map? _read(String id, Set gone) { + final file = File(_pathOf(id)); + try { + final event = jsonDecode(utf8.decode(file.readAsBytesSync())); + if (event is Map) return event; + throw const FormatException('Not a JSON object'); + } on PathNotFoundException { + // Sent by another client using the same directory. + gone.add(id); + } on FormatException catch (e) { + _logger()?.warn('Deleting queued event $id, which cannot be read:', e); + gone.add(id); + _delete(file); + } on FileSystemException catch (e) { + _logger()?.warn('Skipping queued event $id for now:', e); + } + return null; + } + + /// Removes the events with the given [ids]; unknown ids are ignored. + void remove(Iterable ids) { + final removed = ids.toSet(); + _ids.removeWhere(removed.contains); + removed.forEach(_forget); + } + + /// Removes up to [count] events from the front of the queue. + void removeOldest(int count) { + final oldest = _ids.take(count).toList(); + _ids.removeRange(0, oldest.length); + oldest.forEach(_forget); + } + + void _forget(String id) { + if (_unwritten.remove(id) == null) _delete(File(_pathOf(id))); + } + + void _delete(File file) { + try { + file.deleteSync(); + } on PathNotFoundException { + // Already deleted by another client using the same directory. + } on FileSystemException catch (e) { + _logger() + ?.warn('Failed to delete a queued event; it may be sent again:', e); + } + } +} + +/// Replaces [file] with [contents] through a temporary file and a rename, so +/// the file never exists half-written. Creates the directory when missing. +void _writeAtomically(File file, String contents) { + final tmp = File('${file.path}.tmp'); + try { + tmp.writeAsStringSync(contents); + } on PathNotFoundException { + tmp.parent.createSync(recursive: true); + tmp.writeAsStringSync(contents); + } + tmp.renameSync(file.path); +} diff --git a/posthog_flutter/lib/src/core/logger.dart b/posthog_flutter/lib/src/core/logger.dart new file mode 100644 index 00000000..dadffcf2 --- /dev/null +++ b/posthog_flutter/lib/src/core/logger.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +/// Logger used internally by PostHog. +class CoreLogger { + static const _prefix = '[PostHog]'; + + final void Function(void Function()) _maybeCall; + + CoreLogger(this._maybeCall); + + void info(Object? message, [Object? arg]) { + _maybeCall(() => + debugPrint('$_prefix [INFO] $message${arg != null ? ' $arg' : ''}')); + } + + void warn(Object? message, [Object? arg]) { + _maybeCall(() => + debugPrint('$_prefix [WARN] $message${arg != null ? ' $arg' : ''}')); + } + + void error(Object? message, [Object? arg]) { + _maybeCall(() => + debugPrint('$_prefix [ERROR] $message${arg != null ? ' $arg' : ''}')); + } +} diff --git a/posthog_flutter/lib/src/core/persistence.dart b/posthog_flutter/lib/src/core/persistence.dart new file mode 100644 index 00000000..57612edf --- /dev/null +++ b/posthog_flutter/lib/src/core/persistence.dart @@ -0,0 +1,16 @@ +/// Keys for persisted properties. +enum PostHogPersistedProperty { + anonymousId('anonymous_id'), + distinctId('distinct_id'), + deviceId('device_id'), + props('props'), + enablePersonProcessing('enable_person_processing'), + personMode('person_mode'), + featureFlagDetails('feature_flag_details'), + optedOut('opted_out'), + personProperties('person_properties'), + groupProperties('group_properties'); + + final String key; + const PostHogPersistedProperty(this.key); +} diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart new file mode 100644 index 00000000..77eb9eb1 --- /dev/null +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -0,0 +1,1006 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:meta/meta.dart'; + +import '../feature_flag_result.dart'; +import '../posthog_config.dart'; +import 'feature_flag_utils.dart'; +import 'feature_flags.dart'; +import 'persistence.dart'; +import 'posthog_core_stateless.dart'; +import 'session_manager.dart'; +import 'utils/utils.dart'; +import 'uuid.dart'; + +/// Stateful PostHog client with session management, identity, and feature flags. +/// +/// It extends [PostHogCoreStateless] with state management for sessions, +/// identity, feature flags, and person profiles. +abstract class PostHogCore extends PostHogCoreStateless { + final bool _sendFeatureFlagEvents; + final PostHogPersonProfiles _personProfiles; + + /// The `$feature_flag_called` values already reported, per flag key. + final Map> _flagCallReported = {}; + + /// Whether a /flags request is in flight. + bool _loadingFlags = false; + + /// The reload that waits for the /flags request in flight. + Completer? _pendingFlagsReload; + + final _session = PostHogSessionManager(); + + /// See [registerForSession]. + final Map _sessionProps = {}; + + /// The person properties sent last, so that a duplicate `$set` is skipped. + String? _cachedPersonProperties; + + Map _bootstrappedFlags = const {}; + Map _bootstrappedPayloads = const {}; + + /// Whether a `/flags` request has succeeded since the client started or + /// was reset, quota-limited or not. `$used_bootstrap_value` reports the + /// opposite. + bool _flagsLoadedFromRemote = false; + + /// Whether the flags listeners have been told the outcome of a `/flags` + /// request. + bool _flagsRequestAnnounced = false; + + PostHogCore(PostHogConfig config, {required super.storage}) + : _sendFeatureFlagEvents = config.sendFeatureFlagEvents, + _personProfiles = config.personProfiles, + super(config) { + final bootstrap = config.bootstrap; + if (bootstrap != null) _seedBootstrapIdentity(bootstrap); + // Seeded before identify() or reset() can rotate the anonymous id. + _getDeviceId(); + if (bootstrap != null) { + _seedBootstrapFlags(bootstrap); + _reconcileBootstrapIdentity(bootstrap); + } + _announceStartupFlags(); + if (config.preloadFeatureFlags) _reloadFeatureFlags(); + } + + /// Resets the PostHog state. Clears all persisted properties except the + /// device id, and starts a new session. Queued events are kept. + /// + /// The persisted opt-out is cleared too, so the next client starts from + /// the configured default; this client keeps the current consent decision. + void reset() { + wrap(() { + _sessionProps.clear(); + _flagCallReported.clear(); + _cachedPersonProperties = null; + _bootstrappedFlags = const {}; + _bootstrappedPayloads = const {}; + _flagsLoadedFromRemote = false; + + clearPersistedOptOut(); + for (final property in PostHogPersistedProperty.values) { + // Device-level flag bucketing must not change with the user. + if (property != PostHogPersistedProperty.deviceId && + property != PostHogPersistedProperty.optedOut) { + setPersistedProperty(property, null); + } + } + + _session.restart(); + _reloadFeatureFlags(); + }); + } + + @override + Map getCommonEventProperties() { + final featureFlags = _getFeatureFlags() ?? const {}; + final activeFeatureFlags = [ + for (final entry in featureFlags.entries) + if (entry.value != false) entry.key, + ]; + + return { + if (activeFeatureFlags.isNotEmpty) + r'$active_feature_flags': activeFeatureFlags, + for (final entry in featureFlags.entries) + '\$feature/${entry.key}': entry.value, + ...super.getCommonEventProperties(), + }; + } + + @override + bool isMinimalFlagCalledEventsEnabled() => + _getStoredFlagDetails()?.minimalFlagCalledEvents == true; + + /// Platform context (OS, app, device) attached to every event. It wins + /// over registered and event properties of the same name: it describes + /// the environment the SDK runs in. + @protected + Map getContextProperties(); + + /// Default person properties sent with every feature flag evaluation + /// request. Explicitly set person properties override them. + @protected + Map getDefaultPersonPropertiesForFlags(); + + /// The IANA time zone id of the device, for example `Europe/Berlin`, or + /// null when it is unknown. Sent as `$timezone` with every event, as part + /// of the platform context, and as `timezone` with feature flag requests. + /// Called for every event, so it should be cheap. + @protected + String? getTimezone(); + + Map _enrichProperties(Map? properties) { + final now = DateTime.now().millisecondsSinceEpoch; + // Resolved first: it may start a new session, which the debug + // properties below then describe. + final managedSessionId = _session.touch(now); + final callerSessionId = properties?[r'$session_id']; + final sessionId = callerSessionId is String && callerSessionId.isNotEmpty + ? callerSessionId + : managedSessionId; + final timezone = getTimezone(); + return { + ...props, + ..._sessionProps, + ...?properties, + ...getContextProperties(), + if (timezone != null) r'$timezone': timezone, + ...getCommonEventProperties(), + r'$session_id': sessionId, + r'$process_person_profile': _hasPersonProcessing(), + r'$is_identified': _isIdentified(), + ..._sessionDebugProperties(now), + }; + } + + /// The state of the session and the queue, attached to every event to + /// help debug the SDK. + Map _sessionDebugProperties(int now) { + final startedAt = _session.startedAt; + return { + r'$sdk_debug_session_start': startedAt, + // The wall clock can move backwards. + r'$sdk_debug_current_session_duration': max(0, now - startedAt), + r'$sdk_debug_pending_queue_size': storage.queue.length, + }; + } + + /// Adds [properties] to every later event until [reset], without + /// persisting them. They win over registered properties, and the event's + /// own properties win over them. + void registerForSession(Map properties) { + wrap(() => _sessionProps.addAll(properties)); + } + + /// Returns the current session ID. + /// + /// Reading the id neither extends nor ends the session; events do. A + /// session also ends on [reset], and when a new client is created. + String getSessionId() => _session.id; + + String _getAnonymousId() { + var anonId = + getPersistedProperty(PostHogPersistedProperty.anonymousId); + if (anonId == null) { + anonId = generateUuidV7(); + setPersistedProperty(PostHogPersistedProperty.anonymousId, anonId); + } + return anonId; + } + + /// Returns the current distinct ID. + String getDistinctId() { + return getPersistedProperty(PostHogPersistedProperty.distinctId) ?? + _getAnonymousId(); + } + + /// The id used for device-level flag bucketing: the first anonymous id, + /// kept through identify() and reset(). + String _getDeviceId() { + var deviceId = + getPersistedProperty(PostHogPersistedProperty.deviceId); + if (deviceId == null) { + deviceId = _getAnonymousId(); + setPersistedProperty(PostHogPersistedProperty.deviceId, deviceId); + } + return deviceId; + } + + /// Seeds the bootstrapped identity on a fresh install only: once anything + /// is persisted, the stored identity wins. + void _seedBootstrapIdentity(PostHogBootstrapConfig bootstrap) { + final distinctId = bootstrap.distinctId; + if (distinctId == null || distinctId.trim().isEmpty) { + if (bootstrap.isIdentifiedId) { + logger.warn('bootstrap.isIdentifiedId is true, but distinctId is ' + 'empty. The identified bootstrap is ignored.'); + } + return; + } + if (getPersistedProperty(PostHogPersistedProperty.anonymousId) != + null || + getPersistedProperty(PostHogPersistedProperty.distinctId) != + null) { + return; + } + + if (bootstrap.isIdentifiedId) { + // The anonymous (and so the device) id is still generated as usual: + // device-level bucketing must never use the person's id. + setPersistedProperty(PostHogPersistedProperty.distinctId, distinctId); + setPersistedProperty(PostHogPersistedProperty.personMode, 'identified'); + } else { + setPersistedProperty(PostHogPersistedProperty.anonymousId, distinctId); + } + } + + /// Reconciles an identified bootstrap with the user already stored on + /// this device. + void _reconcileBootstrapIdentity(PostHogBootstrapConfig bootstrap) { + final distinctId = bootstrap.distinctId; + if (!bootstrap.isIdentifiedId || + distinctId == null || + distinctId.trim().isEmpty) { + return; + } + + if (getDistinctId() == distinctId) { + // Nothing to merge: the stored anonymous id already is this user. + if (!_isIdentified()) { + setPersistedProperty(PostHogPersistedProperty.personMode, 'identified'); + } + } else if (_isIdentified()) { + logger.warn('bootstrap.distinctId differs from the identified user ' + 'stored on this device, which is kept. Call reset() before ' + 'creating the client to switch users.'); + } else if (optedOut) { + // identify() ignores calls while opted out, but opting out only stops + // events: the identity is still reconciled for a later opt-in. + if (_personProfiles != PostHogPersonProfiles.never) { + setPersistedProperty(PostHogPersistedProperty.distinctId, distinctId); + setPersistedProperty(PostHogPersistedProperty.personMode, 'identified'); + } + } else { + identify(distinctId); + } + } + + /// Identifies the current user, setting [userProperties] and + /// [userPropertiesSetOnce] on the person. + /// + /// - A new [distinctId] for an anonymous user sends `$identify`, linking + /// the anonymous history to the user, and reloads feature flags. + /// - The current distinct id of an anonymous user marks it identified and + /// sends a `$set`; flags are reloaded only when properties were given. + /// - The current distinct id of an identified user only sends the person + /// properties as a deduplicated `$set`, without reloading flags. + /// - A different id while already identified is ignored: call [reset] + /// first to switch users. + /// + /// An empty [distinctId] is ignored, as are all calls while opted out or + /// when `personProfiles` is `never`. + void identify( + String distinctId, { + Map? userProperties, + Map? userPropertiesSetOnce, + }) { + wrap(() { + if (distinctId.trim().isEmpty) { + logger.warn('identify was called with an empty distinct id. ' + 'This call will be ignored.'); + return; + } + if (_ignoredWhileOptedOut('posthog.identify')) return; + if (!_requirePersonProcessing('posthog.identify')) return; + + final previousDistinctId = getDistinctId(); + final isIdentified = _isIdentified(); + final identityChanged = distinctId != previousDistinctId; + if (identityChanged && isIdentified) { + logger.info('identify was called with "$distinctId", but the user is ' + 'already identified as "$previousDistinctId". Call reset() ' + 'before identifying another user. This call will be ignored.'); + return; + } + + final hasUserProperties = (userProperties?.isNotEmpty ?? false) || + (userPropertiesSetOnce?.isNotEmpty ?? false); + + if (identityChanged) { + setPersistedProperty( + PostHogPersistedProperty.anonymousId, previousDistinctId); + setPersistedProperty(PostHogPersistedProperty.distinctId, distinctId); + setPersistedProperty(PostHogPersistedProperty.personMode, 'identified'); + _setPersonPropertiesForFlagsIfNeeded( + userProperties, userPropertiesSetOnce); + + identifyStateless( + distinctId, + properties: _enrichProperties({ + 'distinct_id': distinctId, + r'$anon_distinct_id': previousDistinctId, + ...maybeAdd(r'$set', userProperties), + ...maybeAdd(r'$set_once', userPropertiesSetOnce), + }), + ); + if (userProperties != null || userPropertiesSetOnce != null) { + _cachedPersonProperties = _personPropertiesHash( + distinctId, userProperties, userPropertiesSetOnce); + } + _reloadFeatureFlags(); + } else if (!isIdentified) { + // There is no anonymous history to link: the id already is the + // anonymous one, so the transition is recorded with a $set. + setPersistedProperty(PostHogPersistedProperty.personMode, 'identified'); + capture(r'$set', properties: { + r'$set': userProperties ?? {}, + r'$set_once': userPropertiesSetOnce ?? {}, + }); + if (hasUserProperties) _reloadFeatureFlags(); + } else if (hasUserProperties) { + // Person property changes are processed asynchronously by PostHog, + // so reloading flags right away would not see them. + setPersonProperties( + userPropertiesToSet: userProperties, + userPropertiesToSetOnce: userPropertiesSetOnce, + ); + } else { + logger.info('identify was called with the current distinct id of an ' + 'identified user and no properties. This call will be ignored.'); + } + }); + } + + /// Captures an event. + /// + /// `$set` / `$set_once` maps in [properties] update the person, and ask + /// for person processing even for an anonymous user. `$groups` in + /// [properties] apply to this event only; use [group] to associate the + /// user with a group. + void capture(String event, {Map? properties}) { + wrap(() { + if (_ignoredWhileOptedOut('posthog.capture')) return; + + final userProperties = properties?[r'$set']; + final userPropertiesSetOnce = properties?[r'$set_once']; + if (_personProfiles != PostHogPersonProfiles.never && + (_isNonEmptyMap(userProperties) || + _isNonEmptyMap(userPropertiesSetOnce))) { + _enablePersonProcessing(); + } + _setPersonPropertiesForFlagsIfNeeded( + _asPropertyMap(userProperties), + _asPropertyMap(userPropertiesSetOnce), + ); + + final allProperties = _enrichProperties(properties); + // Once an event is person-processed, all later ones are too. + if (allProperties[r'$process_person_profile'] == true) { + _enablePersonProcessing(); + } + + captureStateless(getDistinctId(), event, properties: allProperties); + if (event == r'$set' || event == r'$identify') { + final set = allProperties[r'$set']; + final setOnce = allProperties[r'$set_once']; + if (set is Map || setOnce is Map) { + _cachedPersonProperties = + _personPropertiesHash(getDistinctId(), set, setOnce); + } + } + }); + } + + /// Creates an alias for a user. An empty [alias] is ignored. + void alias(String alias) { + wrap(() { + if (alias.trim().isEmpty) { + logger.warn('alias was called with an empty alias. ' + 'This call will be ignored.'); + return; + } + if (_ignoredWhileOptedOut('posthog.alias')) return; + if (!_requirePersonProcessing('posthog.alias')) return; + + aliasStateless(alias, getDistinctId(), properties: _enrichProperties({})); + }); + } + + /// Adds group memberships to all later events, reloading feature flags + /// when a group type moves to a different key. + void _registerGroups(Map groups) { + final existingGroups = _getGroups(); + + register({ + r'$groups': {...existingGroups, ...groups}, + }); + + if (groups.keys.any((type) => existingGroups[type] != groups[type])) { + _reloadFeatureFlags(); + } + } + + /// Associates the current user with a group for all later events, and + /// sends a `$groupidentify` event with the optional [groupProperties]. + /// + /// The group properties also feed feature flag evaluation. Flags are + /// reloaded when the group key changes. A call with an empty [groupType] + /// or [groupKey] is ignored. + void group( + String groupType, + String groupKey, { + Map? groupProperties, + }) { + wrap(() { + if (groupType.trim().isEmpty || groupKey.trim().isEmpty) { + logger.warn('group was called with an empty group type or key. ' + 'This call will be ignored.'); + return; + } + if (_ignoredWhileOptedOut('posthog.group')) return; + if (!_requirePersonProcessing('posthog.group')) return; + + // Stored before the group is registered, so the flags reload a new + // group key triggers already evaluates with them. + if (groupProperties != null && groupProperties.isNotEmpty) { + setGroupPropertiesForFlags({groupType: groupProperties}); + } + _registerGroups({groupType: groupKey}); + + groupIdentifyStateless( + groupType, + groupKey, + distinctId: getDistinctId(), + eventProperties: _enrichProperties({}), + groupProperties: groupProperties, + ); + }); + } + + /// Person properties passed to identify() and capture() also feed flag + /// evaluation. $set wins over $set_once for the same key. + void _setPersonPropertiesForFlagsIfNeeded( + Map? userProperties, + Map? userPropertiesSetOnce, + ) { + final merged = { + ...?userPropertiesSetOnce, + ...?userProperties, + }; + if (merged.isEmpty) return; + setPersonPropertiesForFlags(merged); + } + + /// Sets person properties for feature flag evaluation, which the next + /// flags reload sends. + void setPersonPropertiesForFlags(Map properties) { + wrap(() { + final existing = getPersistedProperty>( + PostHogPersistedProperty.personProperties) ?? + {}; + setPersistedProperty( + PostHogPersistedProperty.personProperties, + {...existing, ...toJsonMap(properties, logger)}, + ); + }); + } + + /// Resets person properties for feature flag evaluation. + void resetPersonPropertiesForFlags() { + wrap(() { + setPersistedProperty(PostHogPersistedProperty.personProperties, null); + }); + } + + /// Sets group properties for feature flag evaluation. The /flags API + /// accepts arbitrary JSON property values, so values are not forced to + /// strings. + void setGroupPropertiesForFlags( + Map> properties) { + wrap(() { + final existing = getPersistedProperty>( + PostHogPersistedProperty.groupProperties) ?? + {}; + + final merged = {...existing}; + for (final entry in properties.entries) { + final current = merged[entry.key]; + merged[entry.key] = { + if (current is Map) + for (final e in current.entries) '${e.key}': e.value, + ...toJsonMap(entry.value, logger), + }; + } + + setPersistedProperty(PostHogPersistedProperty.groupProperties, merged); + }); + } + + /// Resets the group properties for feature flag evaluation of + /// [groupType], or of every group type when it is null. + void resetGroupPropertiesForFlags({String? groupType}) { + wrap(() { + if (groupType == null) { + setPersistedProperty(PostHogPersistedProperty.groupProperties, null); + return; + } + final existing = getPersistedProperty>( + PostHogPersistedProperty.groupProperties); + if (existing == null || !existing.containsKey(groupType)) return; + final remaining = {...existing}..remove(groupType); + setPersistedProperty(PostHogPersistedProperty.groupProperties, + remaining.isEmpty ? null : remaining); + }); + } + + Future _flagsAsync() { + if (_loadingFlags) { + logger.info('Feature flags are being loaded already, queuing reload.'); + // Reloads queued behind the one in flight share a single next request: + // it sees every change made meanwhile, so it answers them all. + return (_pendingFlagsReload ??= Completer()).future; + } + return _doFlagsAsync(); + } + + Future _doFlagsAsync() async { + _loadingFlags = true; + try { + final distinctId = getDistinctId(); + final groupsMap = _getGroups(); + final personProperties = { + ...getDefaultPersonPropertiesForFlags(), + ...getPersistedProperty>( + PostHogPersistedProperty.personProperties) ?? + {}, + }; + final groupProperties = getPersistedProperty>( + PostHogPersistedProperty.groupProperties) ?? + {}; + + final timezone = getTimezone(); + final extraProperties = { + r'$anon_distinct_id': _getAnonymousId(), + r'$device_id': _getDeviceId(), + if (timezone != null) 'timezone': timezone, + }; + + final result = await getFlags( + distinctId, + groups: groupsMap, + personProperties: personProperties, + groupProperties: groupProperties.map((k, v) => MapEntry( + k, v is Map ? Map.from(v) : {})), + extraPayload: extraProperties, + ); + + if (result is GetFlagsFailure) { + final stored = _getStoredFlagDetails(); + _setKnownFeatureFlagDetails(PostHogFlagsStorageFormat( + flags: stored?.flags ?? {}, + requestError: result.error, + // A failed request says nothing about the minimal events gate. + minimalFlagCalledEvents: stored?.minimalFlagCalledEvents, + )); + return; + } + + final res = (result as GetFlagsSuccess).response; + // Set before the flags are stored: listeners notified by the store + // may already read (and report) a flag. + _flagsLoadedFromRemote = true; + + if (res.quotaLimited?.contains(QuotaLimitedFeature.featureFlags) == + true) { + // Cached flags keep serving under the quota limit. + final stored = _getStoredFlagDetails(); + _setKnownFeatureFlagDetails(PostHogFlagsStorageFormat( + flags: stored?.flags ?? {}, + requestId: stored?.requestId, + evaluatedAt: stored?.evaluatedAt, + errorsWhileComputingFlags: stored?.errorsWhileComputingFlags, + quotaLimited: res.quotaLimited, + minimalFlagCalledEvents: stored?.minimalFlagCalledEvents, + )); + logger.warn('[FEATURE FLAGS] Feature flags quota limit exceeded.'); + return; + } + + var resolvedFlags = res.flags; + if (res.errorsWhileComputingFlags) { + final currentDetails = _getStoredFlagDetails(); + logger.info( + 'Cached feature flags: ', jsonEncode(currentDetails?.flags)); + + final filteredFlags = {}; + for (final entry in res.flags.entries) { + if (entry.value.failed != true) { + filteredFlags[entry.key] = entry.value; + } + } + + resolvedFlags = { + ...(currentDetails?.flags ?? {}), + ...filteredFlags, + }; + } + + // An empty response is stored too: it clears stale flags and any + // previously recorded request error. + _setKnownFeatureFlagDetails(PostHogFlagsStorageFormat( + flags: resolvedFlags, + requestId: res.requestId, + evaluatedAt: res.evaluatedAt, + errorsWhileComputingFlags: res.errorsWhileComputingFlags, + quotaLimited: res.quotaLimited, + minimalFlagCalledEvents: res.minimalFlagCalledEvents, + )); + } finally { + _loadingFlags = false; + + final pending = _pendingFlagsReload; + if (pending != null) { + _pendingFlagsReload = null; + logger.info('Executing pending feature flags reload.'); + final next = wrap(_flagsAsync); + pending.complete(next); + } + } + } + + void _setKnownFeatureFlagDetails(PostHogFlagsStorageFormat details) { + wrap(() { + setPersistedProperty( + PostHogPersistedProperty.featureFlagDetails, details.toJson()); + _flagsRequestAnnounced = true; + events.emit('featureflags', + PostHogFlagsResponse(flags: details.flags).featureFlags); + }); + } + + /// Announces the flags the client starts with, bootstrapped or cached by an + /// earlier run, without waiting for `/flags`: once listeners can subscribe, + /// after the constructor has returned, unless the outcome of a `/flags` + /// request has been announced by then. + void _announceStartupFlags() { + if (_getStoredFlagDetails() == null) return; + Timer.run(() => wrap(() { + final flags = _getFeatureFlags(); + // Null once reset() has dropped them; its reload announces the next. + if (_flagsRequestAnnounced || flags == null) return; + events.emit('featureflags', flags); + })); + } + + /// Serves the enabled bootstrapped flags until the first complete `/flags` + /// response. They replace any flags persisted by an earlier session. + void _seedBootstrapFlags(PostHogBootstrapConfig bootstrap) { + final flags = {}; + for (final MapEntry(:key, :value) + in (bootstrap.featureFlags ?? {}).entries) { + // Only enabled flags are served: false and '' are dropped. + if (value == true || (value is String && value.isNotEmpty)) { + flags[key] = value; + } else if (value is! bool && value is! String) { + logger.warn( + 'Ignoring bootstrapped feature flag "$key": expected a ' + 'bool or a String, got', + value.runtimeType); + } + } + if (flags.isEmpty) return; + + final payloads = {}; + final details = {}; + for (final MapEntry(:key, :value) in flags.entries) { + final payload = bootstrap.featureFlagPayloads?[key]; + String? encodedPayload; + if (payload != null) { + try { + encodedPayload = jsonEncode(payload); + payloads[key] = payload; + } catch (e) { + logger.warn( + 'Ignoring the bootstrapped payload of feature flag "$key":', e); + } + } + details[key] = PostHogFeatureFlagDetail( + key: key, + enabled: true, + variant: value is String ? value : null, + metadata: encodedPayload != null + ? PostHogFeatureFlagMetadata(payload: encodedPayload) + : null, + ); + } + + _bootstrappedFlags = flags; + _bootstrappedPayloads = payloads; + setPersistedProperty(PostHogPersistedProperty.featureFlagDetails, + PostHogFlagsStorageFormat(flags: details).toJson()); + } + + // A persisted record of an unexpected shape must not turn every capture() + // into a throw - the corrupted key is dropped instead. + T? _discardingMalformed( + PostHogPersistedProperty key, T? Function() parse) { + try { + return parse(); + } catch (e) { + logger.error('Discarding malformed persisted value for ${key.key}:', e); + setPersistedProperty(key, null); + return null; + } + } + + PostHogFlagsStorageFormat? _getStoredFlagDetails() { + return _discardingMalformed(PostHogPersistedProperty.featureFlagDetails, + () { + final raw = getPersistedProperty>( + PostHogPersistedProperty.featureFlagDetails); + if (raw == null) return null; + return PostHogFlagsStorageFormat.fromJson(raw, + onMalformedFlag: (key, e) => + logger.warn('Skipping malformed feature flag "$key":', e)); + }); + } + + /// Gets the result for a specific feature flag, capturing + /// `$feature_flag_called` when [sendEvent] is true. + PostHogFeatureFlagResult? getFeatureFlagResult( + String key, { + required bool sendEvent, + }) { + final storedDetails = _getStoredFlagDetails(); + final details = storedDetails?.toResponse(); + final isQuotaLimited = storedDetails?.quotaLimited + ?.contains(QuotaLimitedFeature.featureFlags) == + true; + final featureFlag = details?.flags[key]; + final flagValue = getFeatureFlagValue(featureFlag); + final shouldSendEvent = + sendEvent && !(_flagCallReported[key]?.contains(flagValue) ?? false); + + if (shouldSendEvent) { + final errors = []; + if (storedDetails?.requestError != null) { + final reqError = storedDetails!.requestError!; + switch (reqError.type) { + case FeatureFlagRequestErrorType.timeout: + errors.add(FeatureFlagErrorType.timeout.value); + case FeatureFlagRequestErrorType.apiError: + if (reqError.statusCode != null) { + errors.add(FeatureFlagErrorType.apiError(reqError.statusCode!)); + } + case FeatureFlagRequestErrorType.connectionError: + errors.add(FeatureFlagErrorType.connectionError.value); + case FeatureFlagRequestErrorType.unknownError: + errors.add(FeatureFlagErrorType.unknownError.value); + } + } else if (storedDetails != null) { + if (storedDetails.errorsWhileComputingFlags == true) { + errors.add(FeatureFlagErrorType.errorsWhileComputing.value); + } + // A value answered from the cache is a valid answer: the quota state + // is only an error when there is nothing to serve. + if (flagValue == null) { + if (isQuotaLimited) { + errors.add(FeatureFlagErrorType.quotaLimited.value); + } else if (featureFlag == null) { + errors.add(FeatureFlagErrorType.flagMissing.value); + } + } + } + + final featureFlagError = errors.isNotEmpty ? errors.join(',') : null; + + (_flagCallReported[key] ??= {}).add(flagValue); + + final bootstrappedValue = _bootstrappedFlags[key]; + final captureProperties = { + r'$feature_flag': key, + r'$feature_flag_response': flagValue, + ...maybeAdd(r'$feature_flag_id', featureFlag?.metadata?.id), + ...maybeAdd(r'$feature_flag_version', featureFlag?.metadata?.version), + ...maybeAdd(r'$feature_flag_reason', + featureFlag?.reason?.description ?? featureFlag?.reason?.code), + ...maybeAdd(r'$feature_flag_request_id', details?.requestId), + ...maybeAdd(r'$feature_flag_evaluated_at', details?.evaluatedAt), + ...maybeAdd(r'$feature_flag_error', featureFlagError), + ...maybeAdd(r'$feature_flag_has_experiment', + featureFlag?.metadata?.hasExperiment), + if (bootstrappedValue != null) ...{ + r'$feature_flag_bootstrapped_response': bootstrappedValue, + ...maybeAdd(r'$feature_flag_bootstrapped_payload', + _bootstrappedPayloads[key]), + r'$used_bootstrap_value': !_flagsLoadedFromRemote, + }, + }; + + capture(r'$feature_flag_called', properties: captureProperties); + } + + if (flagValue == null) return null; + + // Payloads only accompany enabled flags. + final rawPayload = + featureFlag?.enabled == true ? featureFlag?.metadata?.payload : null; + final payload = rawPayload != null + ? parsePayload(rawPayload, + onMalformed: (e) => logger.warn( + 'The payload of feature flag "$key" is not valid JSON, ' + 'returning no payload:', + e)) + : null; + return PostHogFeatureFlagResult( + key: key, + enabled: flagValue is String ? true : flagValue as bool, + variant: flagValue is String ? flagValue : null, + payload: payload, + ); + } + + /// Gets a feature flag value. + /// + /// Returns null if the flag does not exist or has not been loaded. + PostHogFeatureFlagValue? getFeatureFlag(String key) { + final result = getFeatureFlagResult(key, sendEvent: _sendFeatureFlagEvents); + if (result == null) return null; + return result.variant ?? result.enabled; + } + + Map? _getFeatureFlags() => + _getStoredFlagDetails()?.toResponse().featureFlags; + + /// Checks if a feature flag is enabled. + bool? isFeatureEnabled(String key) { + final response = getFeatureFlag(key); + if (response == null) return null; + if (response is bool) return response; + return true; // String variants are truthy + } + + void _reloadFeatureFlags() { + wrap(() => _flagsAsync().catchError((Object e) { + logger.info('Error reloading feature flags', e); + })); + } + + /// Reloads feature flags. Completes when they are loaded, or when the + /// request failed; the failure is recorded for `$feature_flag_called`. + Future reloadFeatureFlagsAsync() async { + await wrap(_flagsAsync); + } + + /// Registers a callback for when feature flags are loaded. A callback + /// registered right after the client is created also hears about the flags + /// it starts with, bootstrapped or cached. Returns an unsubscribe function. + void Function() onFeatureFlags(void Function() callback) => + events.on('featureflags', (_) => callback()); + + bool _isIdentified() => + getPersistedProperty(PostHogPersistedProperty.personMode) == + 'identified'; + + Map _getGroups() { + final groups = props[r'$groups']; + if (groups is! Map) return {}; + return { + for (final entry in groups.entries) + if (entry.key is String && entry.value is String) + entry.key as String: entry.value as String, + }; + } + + bool _hasPersonProcessing() { + if (_personProfiles == PostHogPersonProfiles.always) return true; + if (_personProfiles == PostHogPersonProfiles.never) return false; + + final isIdentified = _isIdentified(); + final hasGroups = _getGroups().isNotEmpty; + final personProcessingEnabled = getPersistedProperty( + PostHogPersistedProperty.enablePersonProcessing) == + true; + + return isIdentified || hasGroups || personProcessingEnabled; + } + + bool _requirePersonProcessing(String functionName) { + if (_personProfiles == PostHogPersonProfiles.never) { + logger.error( + '$functionName was called, but personProfiles is set to "never". This call will be ignored.'); + return false; + } + + _enablePersonProcessing(); + return true; + } + + void _enablePersonProcessing() { + // capture() lands here for every person-processed event: the flag is + // written only when it changes. + if (getPersistedProperty( + PostHogPersistedProperty.enablePersonProcessing) == + true) { + return; + } + setPersistedProperty(PostHogPersistedProperty.enablePersonProcessing, true); + } + + /// Event-producing calls do nothing while opted out: identity, groups and + /// person properties must not change without the event that reports them. + bool _ignoredWhileOptedOut(String functionName) { + if (!optedOut) return false; + logger.info( + '$functionName was called while opted out. This call will be ignored.'); + return true; + } + + /// Sets properties on the person profile. They also feed feature flag + /// evaluation from the next flags reload on. + void setPersonProperties({ + Map? userPropertiesToSet, + Map? userPropertiesToSetOnce, + }) { + wrap(() { + final isSetEmpty = + userPropertiesToSet == null || userPropertiesToSet.isEmpty; + final isSetOnceEmpty = + userPropertiesToSetOnce == null || userPropertiesToSetOnce.isEmpty; + if (isSetEmpty && isSetOnceEmpty) return; + + if (_ignoredWhileOptedOut('posthog.setPersonProperties')) return; + if (!_requirePersonProcessing('posthog.setPersonProperties')) return; + + final hash = _personPropertiesHash( + getDistinctId(), userPropertiesToSet, userPropertiesToSetOnce); + + if (_cachedPersonProperties == hash) { + logger.info( + 'A duplicate setPersonProperties call was made. It has been ignored.'); + return; + } + + final mergedProperties = { + ...(userPropertiesToSetOnce ?? {}), + ...(userPropertiesToSet ?? {}), + }; + setPersonPropertiesForFlags(mergedProperties); + + capture(r'$set', properties: { + r'$set': userPropertiesToSet ?? {}, + r'$set_once': userPropertiesToSetOnce ?? {}, + }); + }); + } + + static String _personPropertiesHash( + String distinctId, + Object? set, + Object? setOnce, + ) { + Map? asMap(Object? value) { + if (value is! Map || value.isEmpty) return null; + return { + for (final entry in value.entries) entry.key.toString(): entry.value, + }; + } + + return getPersonPropertiesHash(distinctId, asMap(set), asMap(setOnce)); + } + + static bool _isNonEmptyMap(Object? value) => value is Map && value.isNotEmpty; + + static Map? _asPropertyMap(Object? value) { + if (value is! Map) return null; + return { + for (final entry in value.entries) + if (entry.value != null) entry.key.toString(): entry.value as Object, + }; + } +} diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart new file mode 100644 index 00000000..fac6c431 --- /dev/null +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -0,0 +1,656 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:meta/meta.dart'; + +import '../posthog_config.dart'; +import '../posthog_flutter_version.dart'; +import 'event_emitter.dart'; +import 'feature_flag_utils.dart'; +import 'feature_flags.dart'; +import 'file_storage.dart'; +import 'logger.dart'; +import 'persistence.dart'; +import 'utils/utils.dart'; +import 'uuid.dart'; + +/// HTTP error during PostHog fetch. +class PostHogFetchHttpError implements Exception { + final int status; + final String responseBody; + final int reqByteLength; + final Duration? retryAfter; + + PostHogFetchHttpError(this.status, this.responseBody, this.reqByteLength, + {this.retryAfter}); + + @override + String toString() => 'PostHogFetchHttpError: status=$status, ' + 'reqByteLength=$reqByteLength, response=$responseBody'; +} + +/// Network error during PostHog fetch. +class PostHogFetchNetworkError implements Exception { + final Object? cause; + PostHogFetchNetworkError(this.cause); + + @override + String toString() => 'PostHogFetchNetworkError: $cause'; +} + +/// Network failures and transient HTTP statuses are worth retrying; +/// hard 4xx (invalid token, bad payload) will fail the same way again. +/// A redirect is not followed for a POST, so the batch never reached +/// ingestion and stays queued. +bool _isTransientFetchError(Object err) => + err is PostHogFetchNetworkError || + (err is PostHogFetchHttpError && + (err.status >= 500 || + err.status == 429 || + err.status == 408 || + (err.status >= 300 && err.status < 400))); + +/// Only transport failures and gateway errors are worth retrying for +/// `/flags`: any other status is the server's answer for this evaluation. +bool _isRetryableFlagsError(Object err) => + err is PostHogFetchNetworkError || + (err is PostHogFetchHttpError && (err.status == 502 || err.status == 504)); + +/// Base stateless PostHog client: the queue of events, consent, super +/// properties and the requests to PostHog. +abstract class PostHogCoreStateless { + final String _apiKey; + final String _host; + final int _flushAt; + int _maxBatchSize; + final int _maxQueueSize; + final Duration _flushInterval; + final PostHogCompression _compression; + + final bool _defaultOptIn; + + static const _requestTimeout = Duration(seconds: 10); + + // A batch is retried after a transient failure; a flags request only after + // a network error or a gateway timeout. + static const _fetchRetryCount = 3; + static const _fetchRetryDelay = Duration(seconds: 3); + static const _flagsRetryCount = 1; + static const _flagsRetryDelay = Duration(milliseconds: 300); + + /// The latest consent decision made through this client. It wins over the + /// stored one: [clearPersistedOptOut] removes the stored decision while + /// this client keeps honoring it, and a decision the storage cannot + /// persist must still apply. + bool? _optedOutDecision; + + @protected + late final SimpleEventEmitter events; + Timer? _flushTimer; + Timer? _retryAfterTimer; + Future? _flushFuture; + + /// Set by [close]: later calls are ignored, the periodic flush stops, and + /// a failed request is not retried. + bool _closed = false; + @protected + late final CoreLogger logger; + void Function()? _removeDebugCallback; + + /// Where the client keeps its state and the events waiting to be sent. + @protected + final FileStorage storage; + + final _httpClient = HttpClient(); + + PostHogCoreStateless( + PostHogConfig config, { + required this.storage, + }) : _apiKey = config.projectToken, + _host = removeTrailingSlash(config.host), + _flushAt = config.flushAt < 1 ? 1 : config.flushAt, + _maxBatchSize = config.maxBatchSize < 1 ? 1 : config.maxBatchSize, + _maxQueueSize = config.maxQueueSize < 1 ? 1 : config.maxQueueSize, + _flushInterval = config.flushInterval, + _compression = config.compression, + _defaultOptIn = !config.optOut { + logger = CoreLogger(_logMsgIfDebug); + events = SimpleEventEmitter( + onListenerError: (e) => logger.warn('Error in event listener:', e)); + storage.logger = logger; + if (config.debug) { + debug(true); + } + + final queue = storage.queue; + final overflow = queue.length - _maxQueueSize; + if (overflow > 0) { + queue.removeOldest(overflow); + } + + // Events left over from a previous run go out with the first periodic + // flush instead of waiting for the next capture. + if (queue.length > 0) { + _scheduleFlush(); + } + } + + bool get _isDebug => _removeDebugCallback != null; + + void _logMsgIfDebug(void Function() fn) { + if (_isDebug) { + fn(); + } + } + + /// Runs [fn] and returns its result, or skips it and returns null when + /// the client is closed. + @protected + T? wrap(T Function() fn) { + if (_closed) { + logger.info('The client is closed, ignoring the call.'); + return null; + } + return fn(); + } + + /// Gets common event properties. + @protected + Map getCommonEventProperties() { + return { + r'$lib': postHogFlutterSdkName, + r'$lib_version': postHogFlutterVersion, + }; + } + + @protected + T? getPersistedProperty(PostHogPersistedProperty key) { + return storage.getProperty(key); + } + + @protected + void setPersistedProperty(PostHogPersistedProperty key, T? value) { + storage.setProperty(key, value); + } + + /// Whether the user has opted out. + bool get optedOut => + _optedOutDecision ?? + getPersistedProperty(PostHogPersistedProperty.optedOut) ?? + // Unknown consent while the store is unreadable: fail closed. + (storage.isDegraded || !_defaultOptIn); + + /// Opt in to tracking. + void optIn() { + wrap(() => _decideOptOut(false)); + } + + /// Opt out of tracking. + void optOut() { + wrap(() => _decideOptOut(true)); + } + + void _decideOptOut(bool optedOut) { + _optedOutDecision = optedOut; + setPersistedProperty(PostHogPersistedProperty.optedOut, optedOut); + } + + /// Removes the persisted consent decision, so the next client starts from + /// the configured default. This client keeps honoring the decision until + /// it is recreated: a reset never silently re-enables tracking. + @protected + void clearPersistedOptOut() { + _optedOutDecision = optedOut; + setPersistedProperty(PostHogPersistedProperty.optedOut, null); + } + + /// Enables or disables debug mode. + void debug(bool enabled) { + _removeDebugCallback?.call(); + _removeDebugCallback = null; + + if (enabled) { + _removeDebugCallback = + events.onAny((event, payload) => logger.info(event, payload)); + } + } + + Map _buildPayload({ + required String distinctId, + required String event, + required Map properties, + }) { + var eventProperties = { + ...properties, + ...getCommonEventProperties(), + }; + // Minimized once all SDK properties are in. + if (event == r'$feature_flag_called' && + eventProperties[r'$feature_flag_has_experiment'] == false && + isMinimalFlagCalledEventsEnabled()) { + eventProperties = { + for (final entry in eventProperties.entries) + if (minimalFeatureFlagCalledProperties.contains(entry.key)) + entry.key: entry.value, + }; + } + return { + 'distinct_id': distinctId, + 'event': event, + 'properties': eventProperties, + }; + } + + /// Whether the server asked for minimal `$feature_flag_called` events. + @protected + bool isMinimalFlagCalledEventsEnabled(); + + @protected + void identifyStateless( + String distinctId, { + required Map properties, + }) { + _enqueue( + 'identify', + _buildPayload( + distinctId: distinctId, + event: r'$identify', + properties: properties, + ), + ); + } + + @protected + void captureStateless( + String distinctId, + String event, { + required Map properties, + }) { + _enqueue( + 'capture', + _buildPayload( + distinctId: distinctId, + event: event, + properties: properties, + ), + ); + } + + @protected + void aliasStateless( + String alias, + String distinctId, { + required Map properties, + }) { + _enqueue( + 'alias', + _buildPayload( + distinctId: distinctId, + event: r'$create_alias', + properties: { + ...properties, + 'distinct_id': distinctId, + 'alias': alias, + }, + ), + ); + } + + @protected + void groupIdentifyStateless( + String groupType, + String groupKey, { + required String distinctId, + required Map eventProperties, + Map? groupProperties, + }) { + _enqueue( + 'capture', + _buildPayload( + distinctId: distinctId, + event: r'$groupidentify', + properties: { + r'$group_type': groupType, + r'$group_key': groupKey, + r'$group_set': groupProperties ?? {}, + ...eventProperties, + }, + ), + ); + } + + @protected + Future getFlags( + String distinctId, { + required Map groups, + // The /flags API accepts arbitrary JSON property values (bool/int/...), + // so the parameters must not force String values. + required Map personProperties, + required Map> groupProperties, + required Map extraPayload, + }) async { + final url = '$_host/flags/?v=2&config=true'; + + final requestData = { + 'token': _apiKey, + 'distinct_id': distinctId, + 'groups': groups, + 'person_properties': personProperties, + 'group_properties': groupProperties, + ...extraPayload, + }; + + logger.info('Flags URL', url); + + try { + final response = await _fetchWithRetry( + url, + jsonEncode(requestData), + retryCount: _flagsRetryCount, + retryDelay: _flagsRetryDelay, + retryCheck: _isRetryableFlagsError, + ); + final json = jsonDecode(response) as Map; + return GetFlagsSuccess(parseFlagsResponse(json, + onMalformedFlag: (key, e) => + logger.warn('Skipping malformed feature flag "$key":', e))); + } catch (e) { + events.emit('error', e); + return GetFlagsFailure(_categorizeRequestError(e)); + } + } + + FeatureFlagRequestError _categorizeRequestError(Object error) { + if (error is PostHogFetchHttpError) { + return FeatureFlagRequestError( + type: FeatureFlagRequestErrorType.apiError, statusCode: error.status); + } + if (error is PostHogFetchNetworkError) { + if (error.cause is TimeoutException) { + return const FeatureFlagRequestError( + type: FeatureFlagRequestErrorType.timeout); + } + return const FeatureFlagRequestError( + type: FeatureFlagRequestErrorType.connectionError); + } + return const FeatureFlagRequestError( + type: FeatureFlagRequestErrorType.unknownError); + } + + @protected + Map get props { + return getPersistedProperty>( + PostHogPersistedProperty.props) ?? + {}; + } + + void register(Map properties) { + wrap(() { + setPersistedProperty(PostHogPersistedProperty.props, + {...props, ...toJsonMap(properties, logger)}); + }); + } + + void unregister(String property) { + wrap(() { + final updated = {...props}..remove(property); + setPersistedProperty(PostHogPersistedProperty.props, updated); + }); + } + + void _enqueue(String type, Map message) { + final prepared = { + ...message, + if (message['properties'] case final Map properties) + 'properties': _serializableProperties(properties), + 'type': type, + 'library': postHogFlutterSdkName, + 'library_version': postHogFlutterVersion, + 'timestamp': currentISOTime(), + 'uuid': generateUuidV7(), + }; + final queue = storage.queue; + final overflow = queue.length - _maxQueueSize + 1; + if (overflow > 0) { + queue.removeOldest(overflow); + logger.warn('Queue is full, the oldest event is dropped.'); + } + queue.add(prepared); + + events.emit(type, prepared); + + if (queue.length >= _flushAt) { + _flushBackground(); + } + + _scheduleFlush(); + } + + /// The event properties as they are queued and sent: values JSON cannot + /// represent are converted, and null-valued object members are left out, + /// as the capture spec requires. Null keeps its meaning only in the + /// `$feature_flag_response` of a flag that has no value. + Map _serializableProperties( + Map properties) => + { + for (final MapEntry(:key, :value) in properties.entries) + if (value != null || key == r'$feature_flag_response') + '$key': toJsonValue(value, logger, dropNullMembers: true), + }; + + void _scheduleFlush() { + if (!_closed && + _retryAfterTimer == null && + _flushInterval > Duration.zero && + _flushTimer == null) { + _flushTimer = Timer(_flushInterval, () { + // Cleared first: a timer firing during a flush in flight only joins + // that flush, and a stale reference would block every later re-arm. + _flushTimer = null; + _flushBackground(); + }); + } + } + + void _clearFlushTimer() { + _flushTimer?.cancel(); + _flushTimer = null; + } + + void _pauseFlush(Duration delay) { + _clearFlushTimer(); + _retryAfterTimer?.cancel(); + _retryAfterTimer = + Timer(delay < _fetchRetryDelay ? _fetchRetryDelay : delay, () { + _retryAfterTimer = null; + if (!_closed && + _flushInterval > Duration.zero && + storage.queue.length > 0) { + _flushBackground(); + } + }); + } + + void _flushBackground() { + flush().catchError((e) { + logger.error('Error while flushing PostHog', e); + }); + } + + /// Flushes the queue of pending events. + /// If a flush is already in progress, returns the existing future to avoid + /// concurrent flushes sending duplicate events. + Future flush() { + if (_flushFuture != null) return _flushFuture!; + if (_retryAfterTimer != null) return Future.value(); + _flushFuture = _doFlush().whenComplete(() => _flushFuture = null); + return _flushFuture!; + } + + Future _doFlush() async { + _clearFlushTimer(); + + final queue = storage.queue; + final sentMessages = >[]; + + while (!_closed) { + // Peeked again after every request, so events queued in the meantime + // go out with the same flush. + final batch = queue.peek(_maxBatchSize); + if (batch.isEmpty) break; + final batchMessages = [for (final queued in batch) queued.event]; + + final data = { + 'api_key': _apiKey, + 'batch': batchMessages, + 'sent_at': currentISOTime(), + }; + + final payload = jsonEncode(data); + final url = '$_host/batch/'; + + try { + await _fetchWithRetry(url, payload, honorRetryAfter: true); + } catch (e) { + // A client closed meanwhile leaves the queue to the next one. + if (_closed) rethrow; + if (e is PostHogFetchHttpError && + e.status == 413 && + batchMessages.length > 1) { + _maxBatchSize = (batchMessages.length ~/ 2).clamp(1, _maxBatchSize); + logger.warn('Received 413, reducing batch size to $_maxBatchSize'); + continue; + } + + if (!_isTransientFetchError(e)) { + queue.remove([for (final queued in batch) queued.id]); + } + // Re-arm the periodic flush: otherwise queued events sit until the + // next capture (forever in an idle app after an offline failure). + if (_isTransientFetchError(e) && + e is PostHogFetchHttpError && + e.retryAfter != null) { + _pauseFlush(e.retryAfter!); + } else { + _scheduleFlush(); + } + events.emit('error', e); + rethrow; + } + + // Removed by id: the queue may have changed while the batch was in + // flight, e.g. its oldest events dropped to make room. + queue.remove([for (final queued in batch) queued.id]); + sentMessages.addAll(batchMessages); + } + + if (sentMessages.isNotEmpty) { + events.emit('flush', sentMessages); + } + } + + /// Posts [body] to [url] and returns the response body, retrying the + /// failures [retryCheck] accepts. + Future _fetchWithRetry( + String url, + String body, { + int retryCount = _fetchRetryCount, + Duration retryDelay = _fetchRetryDelay, + bool Function(Object error) retryCheck = _isTransientFetchError, + bool honorRetryAfter = false, + }) async { + return retriable( + () async { + final ({int status, String body, Duration? retryAfter}) response; + try { + response = await _fetch(url, body).timeout(_requestTimeout); + } catch (e) { + throw PostHogFetchNetworkError(e); + } + + if (response.status < 200 || response.status >= 300) { + throw PostHogFetchHttpError( + response.status, + response.body, + body.length, + retryAfter: response.retryAfter, + ); + } + return response.body; + }, + retryCount: retryCount, + retryDelay: retryDelay, + retryCheck: (error) => + !_closed && + retryCheck(error) && + !(honorRetryAfter && + error is PostHogFetchHttpError && + error.retryAfter != null), + ); + } + + static Duration? _parseRetryAfter(String? value) { + if (value == null) return null; + final seconds = int.tryParse(value.trim()); + final Duration delay; + if (seconds != null) { + delay = Duration(seconds: seconds); + } else { + try { + delay = HttpDate.parse(value).difference(DateTime.now().toUtc()); + } on HttpException { + return null; + } + } + return delay > Duration.zero ? delay : null; + } + + Future<({int status, String body, Duration? retryAfter})> _fetch( + String url, String body) async { + final request = await _httpClient.postUrl(Uri.parse(url)); + // A redirect is reported rather than followed, so a redirected batch is + // not taken for delivered. + request.followRedirects = false; + // PostHog infers the flag evaluation runtime from the User-Agent: an + // agent it does not recognize as a client SDK receives no client-only + // flags. + request.headers + ..set(HttpHeaders.userAgentHeader, + '$postHogFlutterSdkName/$postHogFlutterVersion') + ..set(HttpHeaders.contentTypeHeader, 'application/json'); + final bytes = utf8.encode(body); + final List payload; + if (_compression == PostHogCompression.gzip) { + request.headers.set(HttpHeaders.contentEncodingHeader, 'gzip'); + payload = gzip.encode(bytes); + } else { + payload = bytes; + } + request.contentLength = payload.length; + request.add(payload); + + final response = await request.close(); + final responseBody = await response + .transform(const Utf8Decoder(allowMalformed: true)) + .join(); + return ( + status: response.statusCode, + body: responseBody, + retryAfter: _parseRetryAfter( + response.headers.value(HttpHeaders.retryAfterHeader)), + ); + } + + /// Closes the client without sending anything: stops the periodic flush, + /// aborts the requests in flight, ignores later calls and releases the + /// storage. Queued events stay stored for the next client. + void close() { + _closed = true; + _clearFlushTimer(); + _retryAfterTimer?.cancel(); + _retryAfterTimer = null; + _httpClient.close(force: true); + storage.close(); + } +} diff --git a/posthog_flutter/lib/src/core/session_manager.dart b/posthog_flutter/lib/src/core/session_manager.dart new file mode 100644 index 00000000..6710f647 --- /dev/null +++ b/posthog_flutter/lib/src/core/session_manager.dart @@ -0,0 +1,48 @@ +import 'uuid.dart'; + +/// The session that events are attributed to. A session ends after 30 +/// minutes without events or 24 hours after it started, and the event that +/// follows starts a new one. +/// +/// Times are wall-clock milliseconds since the epoch, so that a session also +/// ends while the device sleeps. +class PostHogSessionManager { + /// Starts the first session. + PostHogSessionManager() { + restart(); + } + + static const _inactivityTimeout = Duration(minutes: 30); + static const _maxLength = Duration(hours: 24); + + late String _id; + late int _startedAt; + late int _lastActiveAt; + + /// The id of the current session. Reading it neither extends nor ends the + /// session. + String get id => _id; + + /// When the current session started. + int get startedAt => _startedAt; + + /// Ends the current session and starts a new one. + void restart() => _startAt(DateTime.now().millisecondsSinceEpoch); + + /// Returns the session of an event at [now]: the current one, which the + /// event keeps active, or a new one when the current one has ended. + String touch(int now) { + if (now - _lastActiveAt > _inactivityTimeout.inMilliseconds || + now - _startedAt > _maxLength.inMilliseconds) { + _startAt(now); + } + _lastActiveAt = now; + return _id; + } + + void _startAt(int now) { + _id = generateUuidV7(); + _startedAt = now; + _lastActiveAt = now; + } +} diff --git a/posthog_flutter/lib/src/core/utils/utils.dart b/posthog_flutter/lib/src/core/utils/utils.dart new file mode 100644 index 00000000..9f9dd50b --- /dev/null +++ b/posthog_flutter/lib/src/core/utils/utils.dart @@ -0,0 +1,137 @@ +import 'dart:collection'; +import 'dart:convert'; + +import '../logger.dart'; + +/// Removes trailing slashes from a URL. +String removeTrailingSlash(String url) { + return url.replaceAll(RegExp(r'/+$'), ''); +} + +/// Returns current time as ISO 8601 string. +String currentISOTime() => DateTime.now().toUtc().toIso8601String(); + +/// Retries an async function with configurable options. +Future retriable( + Future Function() fn, { + required int retryCount, + required Duration retryDelay, + required bool Function(Object) retryCheck, +}) async { + Object? lastError; + + for (var i = 0; i < retryCount + 1; i++) { + if (i > 0) { + await Future.delayed(retryDelay); + } + + try { + return await fn(); + } catch (e) { + lastError = e; + if (!retryCheck(e)) { + rethrow; + } + } + } + + throw lastError!; +} + +/// Returns [value] as a new structure that `jsonEncode` accepts. +/// +/// A [DateTime] becomes its ISO 8601 string in UTC and a [Uri] its string. +/// Other values JSON cannot represent are converted like `jsonEncode` does, +/// through their `toJson()`, or else sent as their `toString()` with a +/// warning to [logger]. Map keys become strings. With [dropNullMembers], +/// null-valued map entries are left out at every level, while list +/// elements keep their positions. +Object? toJsonValue(Object? value, CoreLogger logger, + {bool dropNullMembers = false}) { + // The maps and lists being converted: one that contains itself goes to + // the fallback below, which reports it, instead of recursing forever. + final converting = Set.identity(); + + Object? convert(Object? value) { + switch (value) { + case null || bool() || String() || int() || double(isFinite: true): + return value; + case final DateTime date: + return date.toUtc().toIso8601String(); + case final Uri uri: + return uri.toString(); + case final Map map: + if (!converting.add(map)) break; + try { + return { + for (final MapEntry(:key, value: member) in map.entries) + if (member != null || !dropNullMembers) '$key': convert(member), + }; + } finally { + converting.remove(map); + } + case final List list: + if (!converting.add(list)) break; + try { + return [for (final element in list) convert(element)]; + } finally { + converting.remove(list); + } + } + try { + return convert(jsonDecode(jsonEncode(value))); + } on JsonUnsupportedObjectError { + logger.warn('Sending a property value of type ${value.runtimeType} as ' + 'its toString(): it cannot be JSON-encoded.'); + return value.toString(); + } + } + + return convert(value); +} + +/// [toJsonValue] for a map, typed as the JSON object it becomes. +Map toJsonMap(Map map, CoreLogger logger) => + toJsonValue(map, logger) as Map; + +/// Returns a single-entry map if [value] is non-null, empty map otherwise. +Map maybeAdd(String key, Object? value) { + if (value != null) { + return {key: value}; + } + return {}; +} + +/// Recursively sorts all keys in a map for deterministic serialization. +/// Uses [SplayTreeMap] which maintains keys in sorted order. +Object? deepSortKeys(Object? value) { + if (value is Map) { + // Nested maps often arrive untyped (map literals, jsonDecode output), + // so entries are copied over instead of casting the whole map. + final sorted = SplayTreeMap(); + for (final entry in value.entries) { + sorted['${entry.key}'] = deepSortKeys(entry.value); + } + return sorted; + } + if (value is List) { + return value.map(deepSortKeys).toList(); + } + return value; +} + +/// Creates a deterministic hash string from distinct_id and person properties. +String getPersonPropertiesHash( + String distinctId, + Map? userPropertiesToSet, + Map? userPropertiesToSetOnce, +) { + return jsonEncode({ + 'distinct_id': distinctId, + 'userPropertiesToSet': + userPropertiesToSet != null ? deepSortKeys(userPropertiesToSet) : null, + 'userPropertiesToSetOnce': userPropertiesToSetOnce != null + ? deepSortKeys(userPropertiesToSetOnce) + : null, + }); +} diff --git a/posthog_flutter/lib/src/core/uuid.dart b/posthog_flutter/lib/src/core/uuid.dart new file mode 100644 index 00000000..ded889ed --- /dev/null +++ b/posthog_flutter/lib/src/core/uuid.dart @@ -0,0 +1,76 @@ +import 'dart:math'; +import 'dart:typed_data'; + +/// Generates a UUID version 7 (RFC 9562): a millisecond Unix timestamp +/// followed by random bits. +/// +/// The ids generated in one isolate sort lexically in the order they were +/// generated, also within one millisecond and when the wall clock steps +/// back: the random bits then continue as a counter (RFC 9562, section 6.2, +/// method 2). Queue file names rely on this order. +String generateUuidV7() => _generator.next(); + +final _generator = _UuidV7Generator(); + +class _UuidV7Generator { + final _random = Random.secure(); + + /// The 74 bits between the timestamp and the end of the id that are not + /// version or variant bits, big-endian. + final _counter = Uint8List(10); + + /// The bits of counter byte [i] that are part of the counter: bytes 0 and + /// 2 share the id's bytes with the version and the variant. + static int _counterMask(int i) => + switch (i) { 0 => 0x0f, 2 => 0x3f, _ => 0xff }; + + int _millis = -1; + + String next() { + final now = DateTime.now().millisecondsSinceEpoch; + if (now > _millis) { + _millis = now; + _reseed(); + } else if (!_increment()) { + // All 74 bits used up within one millisecond: move to the next one. + _millis++; + _reseed(); + } + return _format(); + } + + void _reseed() { + for (var i = 0; i < _counter.length; i++) { + _counter[i] = _random.nextInt(256) & _counterMask(i); + } + } + + /// Adds one to the counter. Returns false when it wraps around to zero. + bool _increment() { + for (var i = _counter.length - 1; i >= 0; i--) { + _counter[i] = (_counter[i] + 1) & _counterMask(i); + if (_counter[i] != 0) return true; + } + return false; + } + + String _format() { + final bytes = Uint8List(16); + var millis = _millis; + for (var i = 5; i >= 0; i--) { + bytes[i] = millis % 256; + millis ~/= 256; + } + bytes[6] = 0x70 | _counter[0]; // version 7 + bytes[7] = _counter[1]; + bytes[8] = 0x80 | _counter[2]; // variant 10xx + bytes.setRange(9, 16, _counter, 3); + + final hex = [ + for (final byte in bytes) byte.toRadixString(16).padLeft(2, '0'), + ].join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-' + '${hex.substring(20)}'; + } +} diff --git a/posthog_flutter/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart b/posthog_flutter/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart index fa5d826d..716dd04c 100644 --- a/posthog_flutter/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart +++ b/posthog_flutter/lib/src/error_tracking/posthog_error_tracking_autocapture_integration.dart @@ -5,8 +5,6 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; -import 'package:posthog_flutter/src/util/platform_io_stub.dart' - if (dart.library.io) 'package:posthog_flutter/src/util/platform_io_real.dart'; import 'isolate_handler_io.dart' if (dart.library.js_interop) 'isolate_handler_web.dart'; @@ -174,12 +172,6 @@ class PostHogErrorTrackingAutoCaptureIntegration { /// Platform error handler for Dart runtime errors void _setupPlatformErrorHandler() { - // On web, PlatformDispatcher.onError is not implemented. Skip for now - // See: https://github.com/flutter/flutter/issues/100277 - if (!isSupportedPlatform()) { - return; - } - // prevent circular calls if (PlatformDispatcher.instance.onError == _posthogPlatformErrorHandler) { return; @@ -210,9 +202,8 @@ class PostHogErrorTrackingAutoCaptureIntegration { } // https://docs.flutter.dev/perf/isolates#web-platforms-and-compute - // web has no isolates support, and isSupportedPlatform() returns true - // there (its web stub reports kIsWeb), so check kIsWeb explicitly - if (kIsWeb || !isSupportedPlatform()) { + // web has no isolates support + if (kIsWeb) { return; } diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 35e8a56b..8f9fd859 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -270,6 +270,8 @@ class Posthog { /// /// Returns a [Future] that completes when the record has been forwarded. /// + /// **Windows/Linux:** not supported. + /// /// **Example:** /// ```dart /// await Posthog().captureLog( @@ -628,7 +630,7 @@ class Posthog { /// a token registered before they are initialized, and this method still /// completes without an error. /// - /// Not supported on Flutter web or macOS. + /// Not supported on Flutter web, macOS, Windows or Linux. Future registerPushNotificationToken( String deviceToken, { String? appId, @@ -643,7 +645,7 @@ class Posthog { /// moves a registered token to the new anonymous identity on its own, so this /// is only needed when you manage subscriptions yourself. /// - /// Not supported on Flutter web or macOS. + /// Not supported on Flutter web, macOS, Windows or Linux. Future unregisterPushNotificationToken() => _posthog.unregisterPushNotificationToken(); @@ -685,7 +687,7 @@ class Posthog { /// /// [subtitle] is iOS only and ignored on Android, which has no such field. /// - /// Not supported on Flutter web. + /// Not supported on Flutter web, Windows or Linux. Future capturePushNotificationOpened({ String? title, String? subtitle, @@ -823,6 +825,8 @@ class Posthog { /// - Flutter web: forwarded to posthog-js. Steps attach to exceptions /// captured by posthog-js, but not to exceptions captured via /// [captureException] on web. + /// - Windows/Linux: the buffer is kept in memory by the Dart + /// implementation, and no native crashes are captured on desktop. /// /// **Example:** /// ```dart @@ -896,6 +900,8 @@ class Posthog { /// restarts the recording even when the platform keeps the current session id. /// /// Returns a [Future] that completes when the start request has been sent. + /// + /// **Windows/Linux:** not supported. Future startSessionRecording({bool resumeCurrent = true}) async { if (!resumeCurrent) { // The new recording must send its own meta event rather than inherit the @@ -911,6 +917,8 @@ class Posthog { /// This method will have no effect if PostHog is not enabled. /// /// Returns a [Future] that completes when the stop request has been sent. + /// + /// **Windows/Linux:** not supported. Future stopSessionRecording() async { await _posthog.stopSessionRecording(); PostHogInternalEvents.sessionRecordingActive.value = false; @@ -920,6 +928,8 @@ class Posthog { /// /// Returns `false` when session replay is inactive or unsupported by the /// current platform. + /// + /// **Windows/Linux:** not supported. Future isSessionReplayActive() => _posthog.isSessionReplayActive(); Posthog._internal(); diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index 31ecf8d4..c36b2e5f 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -216,6 +216,8 @@ class PostHogConfig { /// - For Flutter web, this setting will be ignored. Surveys on web use the /// JavaScript Web SDK instead. /// - See: https://posthog.com/docs/surveys/installation?tab=Web + /// - Surveys are not supported on Windows and Linux, where this setting is + /// ignored. /// /// Defaults to true. var surveys = true; @@ -235,9 +237,10 @@ class PostHogConfig { /// return caller-provided values before the first `/flags` response. Mirrors /// the [`bootstrap` option in `posthog-js`](https://posthog.com/docs/feature-flags/bootstrapping). /// - /// Forwarded to the native iOS/Android SDKs, which apply all precedence rules - /// (never overwrite persisted identity, overlay loaded flags over bootstrapped - /// ones, drop the bootstrap on `reset()`). Defaults to `null` (no bootstrap). + /// Forwarded to the native iOS/Android SDKs, and on Windows/Linux to the + /// pure-Dart implementation, which apply all precedence rules (never + /// overwrite persisted identity, overlay loaded flags over bootstrapped ones, + /// drop the bootstrap on `reset()`). Defaults to `null` (no bootstrap). /// /// **Flutter web:** not applied. The web SDK hooks onto an already-initialized /// posthog-js instance, so configure `bootstrap` in your `posthog.init({...})` @@ -261,7 +264,7 @@ class PostHogConfig { /// The startup fetch does not see later token refreshes; wire those to /// [Posthog.registerPushNotificationToken] yourself. /// - /// **Flutter web:** not supported. Defaults to `true`. + /// **Flutter web, Windows and Linux:** not supported. Defaults to `true`. bool capturePushNotificationSubscriptions = true; /// Whether to automatically capture `$push_notification_opened` when a user @@ -274,7 +277,7 @@ class PostHogConfig { /// so push delivered outside FCM is not seen. Call /// [Posthog.capturePushNotificationOpened] for the opens this misses. /// - /// **Flutter web:** not supported. Defaults to `true`. + /// **Flutter web, Windows and Linux:** not supported. Defaults to `true`. /// /// On iOS this requires your app to set `UNUserNotificationCenter.current().delegate`. /// Without one, iOS reports the tap to nobody and no open can be captured. @@ -305,7 +308,7 @@ class PostHogConfig { /// installed. Set `com.posthog.posthog.AUTO_INIT` to `false` and call /// [Posthog.setup]. /// - /// **Flutter web:** not supported. Defaults to `null`. + /// **Flutter web, Windows and Linux:** not supported. Defaults to `null`. PushIdentityProvider? pushIdentityProvider; /// Callbacks to intercept and modify events before they are sent to PostHog. @@ -541,6 +544,9 @@ class PostHogBootstrapConfig { /// hooks onto an already-initialized posthog-js instance, so configure logs in /// your `posthog.init({...})` call instead. Only [beforeSend] runs on web (in /// Dart). +/// +/// **Windows and Linux:** logs are not supported. Only [beforeSend] runs (in +/// Dart); the records it keeps are then dropped. class PostHogLogsConfig { /// Creates a logs configuration with native defaults. PostHogLogsConfig(); @@ -1135,6 +1141,8 @@ class PostHogErrorTrackingConfig { /// the PostHog Gradle plugin to upload ProGuard/R8 mappings. /// See: https://posthog.com/docs/error-tracking/upload-mappings/android /// + /// **Windows and Linux:** not supported. + /// /// Default: false var captureNativeExceptions = false; @@ -1157,6 +1165,7 @@ class PostHogErrorTrackingConfig { /// - Apple platforms: Not applicable (native crash capture is part of /// [captureNativeExceptions]) /// - Flutter web: Not supported + /// - Windows and Linux: Not supported /// /// Default: false var captureNativeCrashes = false; @@ -1214,6 +1223,10 @@ class PostHogErrorTrackingConfig { /// **Flutter web:** the buffer lives in posthog-js. Steps are forwarded to it, /// but they only attach to exceptions captured by posthog-js itself, not to /// exceptions captured via `Posthog().captureException()` on web. +/// +/// **Windows and Linux:** the buffer is kept in memory and attaches to every +/// `$exception` captured by the app. There are no native crash reports to +/// attach it to. class PostHogExceptionStepsConfig { /// Creates an exception-steps configuration with native defaults. PostHogExceptionStepsConfig(); diff --git a/posthog_flutter/lib/src/posthog_desktop_app_info.dart b/posthog_flutter/lib/src/posthog_desktop_app_info.dart new file mode 100644 index 00000000..f8fc10bb --- /dev/null +++ b/posthog_flutter/lib/src/posthog_desktop_app_info.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'posthog_desktop_version_resource.dart'; +import 'util/logging.dart'; + +/// The name, version and build of a Windows or Linux app. +class DesktopAppInfo { + const DesktopAppInfo({this.name, this.version, this.build}); + + /// What the Flutter build recorded for the running app. Values that cannot + /// be read are null. + factory DesktopAppInfo.fromPlatform() { + try { + if (Platform.isLinux) { + return DesktopAppInfo.fromLinuxBundle(Platform.resolvedExecutable); + } + if (Platform.isWindows) { + final resource = WindowsVersionResource.read( + Platform.resolvedExecutable, + ); + return DesktopAppInfo.fromVersionResource( + productName: resource.productName, + productVersion: resource.productVersion, + ); + } + } catch (e) { + printIfDebug('[PostHog] Could not read the app name and version: $e'); + } + return const DesktopAppInfo(); + } + + /// Reads the `version.json` that a Linux build bundles in + /// `data/flutter_assets`, next to the [executable]. + factory DesktopAppInfo.fromLinuxBundle(String executable) { + final versionFile = File( + '${File(executable).parent.path}/data/flutter_assets/version.json', + ); + final json = jsonDecode(versionFile.readAsStringSync()); + if (json is! Map) return const DesktopAppInfo(); + return DesktopAppInfo( + name: _nonEmpty(json['app_name']), + version: _nonEmpty(json['version']), + build: _nonEmpty(json['build_number']), + ); + } + + /// Reads the strings of a Windows version resource, whose `ProductVersion` + /// the Flutter runner sets to the full app version, e.g. `1.2.3+4`. + factory DesktopAppInfo.fromVersionResource({ + String? productName, + String? productVersion, + }) { + final separator = productVersion?.indexOf('+') ?? -1; + return DesktopAppInfo( + name: _nonEmpty(productName), + version: _nonEmpty( + separator < 0 + ? productVersion + : productVersion!.substring(0, separator), + ), + build: _nonEmpty( + separator < 0 ? null : productVersion!.substring(separator + 1), + ), + ); + } + + /// The app name: the pubspec `name` on Linux, the `ProductName` of the + /// executable on Windows. + final String? name; + + /// The app version without the build number, e.g. `1.2.3`. + final String? version; + + /// The build number, e.g. `4` for version `1.2.3+4`. + final String? build; + + static String? _nonEmpty(Object? value) => + value is String && value.isNotEmpty ? value : null; +} diff --git a/posthog_flutter/lib/src/posthog_desktop_client.dart b/posthog_flutter/lib/src/posthog_desktop_client.dart new file mode 100644 index 00000000..a29b27f1 --- /dev/null +++ b/posthog_flutter/lib/src/posthog_desktop_client.dart @@ -0,0 +1,64 @@ +import 'dart:ui'; + +import 'core/posthog_core.dart'; +import 'posthog_desktop_context.dart'; +import 'posthog_flutter_version.dart'; + +/// The PostHog client of the Windows and Linux implementation. +/// +/// Attaches the static context collected at setup and the current locale to +/// every event. The context is held per instance and never persisted, so each +/// setup() ships fresh values: an updated `$app_version` cannot be shadowed +/// by a stale copy from a previous run. +class DesktopPostHog extends PostHogCore { + DesktopPostHog( + super.config, { + required super.storage, + required Map staticContext, + required String? timezone, + }) : _staticContext = staticContext, + _timezone = timezone; + + final Map _staticContext; + + /// The IANA name of the local time zone. + final String? _timezone; + + @override + Map getContextProperties() { + final language = languageCodeOf(PlatformDispatcher.instance.locale); + return { + ..._staticContext, + if (language != null) r'$locale': language, + }; + } + + // Session replay is not available on desktop. Common properties are merged + // over the event's own, so an event cannot change it. + @override + Map getCommonEventProperties() => { + ...super.getCommonEventProperties(), + r'$recording_status': 'disabled', + }; + + @override + String? getTimezone() => _timezone; + + /// Default person properties sent with every /flags request, so flags can + /// target the app version, the OS and the device type. + @override + Map getDefaultPersonPropertiesForFlags() => { + for (final key in _flagPersonPropertyKeys) + if (_staticContext.containsKey(key)) key: _staticContext[key], + r'$lib': postHogFlutterSdkName, + r'$lib_version': postHogFlutterVersion, + }; + + static const _flagPersonPropertyKeys = [ + r'$app_version', + r'$app_build', + r'$os_name', + r'$os_version', + r'$device_type', + ]; +} diff --git a/posthog_flutter/lib/src/posthog_desktop_context.dart b/posthog_flutter/lib/src/posthog_desktop_context.dart new file mode 100644 index 00000000..5f01b4f8 --- /dev/null +++ b/posthog_flutter/lib/src/posthog_desktop_context.dart @@ -0,0 +1,76 @@ +import 'dart:io'; +import 'dart:ui'; + +import 'posthog_desktop_app_info.dart'; +import 'util/logging.dart'; + +/// Collects the static device and app context of desktop events. App values +/// the build did not record are omitted. +Map collectDesktopContext(DesktopAppInfo app) { + final build = app.build; + return { + if (app.name != null) r'$app_name': app.name, + if (app.version != null) r'$app_version': app.version, + if (build != null) r'$app_build': parseBuildNumber(build), + r'$os_name': _osName(), + r'$os_version': extractOsVersion(Platform.operatingSystemVersion), + r'$device_type': 'Desktop', + ..._screenInfo(), + }; +} + +/// A build number as `$app_build` carries it: an int when [build] is a +/// decimal number, so builds compare as numbers, the string itself otherwise +/// ("1.2.3"). +Object parseBuildNumber(String build) => + int.tryParse(build, radix: 10) ?? build; + +// Only Windows and Linux run this implementation. +String _osName() => Platform.isWindows ? 'Windows' : 'Linux'; + +/// Extracts the numeric OS version from a [Platform.operatingSystemVersion] +/// banner ('"Windows 10 Pro" 10.0 (Build 19043)', +/// 'Linux 5.11.0-1018-gcp #20~20.04.1-Ubuntu ...'). +/// +/// Windows banners fold the build number into the version ('10.0.19043'); +/// other banners yield their first dotted number. A banner with nothing that +/// looks like a version is returned unchanged. +String extractOsVersion(String banner) { + final windows = RegExp(r'(\d+\.\d+)\s+\(Build\s+(\d+)\)').firstMatch(banner); + if (windows != null) return '${windows[1]}.${windows[2]}'; + return RegExp(r'\d+(\.\d+)+').firstMatch(banner)?[0] ?? banner; +} + +/// The language code of [locale] ("en"), reported as `$locale`. +/// +/// Null when the platform reported no locale ("und") or only the POSIX +/// default Linux falls back to ("C"), which names no language. +String? languageCodeOf(Locale locale) { + final language = locale.languageCode; + const noLanguage = {'', 'und', 'C', 'POSIX'}; + return noLanguage.contains(language) ? null : language; +} + +/// Screen size in logical pixels. +/// +/// Best effort, read once at setup: when the engine has not reported the +/// implicit view's display yet, the keys are omitted instead of being read +/// lazily, keeping the context immutable for the client's lifetime. +Map _screenInfo() { + try { + final display = PlatformDispatcher.instance.implicitView?.display; + if (display == null) return {}; + final ratio = display.devicePixelRatio; + final size = display.size; + if (ratio <= 0 || size.isEmpty) return {}; + return { + r'$screen_width': (size.width / ratio).round(), + r'$screen_height': (size.height / ratio).round(), + r'$screen_density': ratio, + }; + } catch (e) { + // FlutterView.display throws while the view is not attached to one. + printIfDebug('[PostHog] Could not read display info: $e'); + return {}; + } +} diff --git a/posthog_flutter/lib/src/posthog_desktop_exception_steps.dart b/posthog_flutter/lib/src/posthog_desktop_exception_steps.dart new file mode 100644 index 00000000..b1c7b896 --- /dev/null +++ b/posthog_flutter/lib/src/posthog_desktop_exception_steps.dart @@ -0,0 +1,86 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import 'core/logger.dart'; +import 'core/utils/utils.dart'; +import 'util/logging.dart'; +import 'utils/property_normalizer.dart'; + +/// The exception steps recorded on desktop (Windows/Linux), attached to every +/// `$exception` captured there as `$exception_steps`. +/// +/// A FIFO bounded by a UTF-8 byte budget: the oldest steps are evicted to make +/// room for a new one, and a step larger than the whole budget is rejected. +/// Steps live in memory only, as desktop captures no crashes that would be +/// reported on the next launch. +class ExceptionStepsBuffer { + ExceptionStepsBuffer({required int maxBytes}) : _maxBytes = max(0, maxBytes); + + static const _messageKey = r'$message'; + static const _timestampKey = r'$timestamp'; + + /// Reports the values a step sends as their string, in debug builds. + static final _logger = CoreLogger((log) { + if (kDebugMode) log(); + }); + + final int _maxBytes; + final _entries = ListQueue<({Map step, int bytes})>(); + int _totalBytes = 0; + + /// The buffered steps, oldest first. + List> get steps => + [for (final entry in _entries) entry.step]; + + /// Records a step describing [message], stamped with the current time. + /// + /// The reserved `$message` and `$timestamp` keys of [properties] are + /// ignored. The step is normalized like event properties before its size is + /// measured, so the budget counts what is sent. + void add(String message, {Map? properties}) { + final timestamp = DateTime.now(); + if (message.isEmpty) { + printIfDebug('[PostHog] addExceptionStep called with an empty message.'); + return; + } + + final step = {}; + properties?.forEach((key, value) { + if (key == _messageKey || key == _timestampKey) { + printIfDebug( + '[PostHog] addExceptionStep: reserved key $key in properties is ignored.', + ); + } else { + step[key] = value; + } + }); + step[_messageKey] = message; + step[_timestampKey] = _millisecondTimestamp(timestamp); + + final normalized = toJsonValue(PropertyNormalizer.normalize(step), _logger, + dropNullMembers: true)! as Map; + final bytes = utf8.encode(jsonEncode(normalized)).length; + if (bytes > _maxBytes) { + printIfDebug( + '[PostHog] Exception step dropped: $bytes bytes exceed maxBytes ($_maxBytes).', + ); + return; + } + + _entries.add((step: normalized, bytes: bytes)); + _totalBytes += bytes; + while (_totalBytes > _maxBytes) { + _totalBytes -= _entries.removeFirst().bytes; + } + } + + /// ISO 8601 in UTC with millisecond precision. + static String _millisecondTimestamp(DateTime time) => + DateTime.fromMillisecondsSinceEpoch( + time.millisecondsSinceEpoch, + isUtc: true, + ).toIso8601String(); +} diff --git a/posthog_flutter/lib/src/posthog_desktop_lifecycle.dart b/posthog_flutter/lib/src/posthog_desktop_lifecycle.dart new file mode 100644 index 00000000..352a4b09 --- /dev/null +++ b/posthog_flutter/lib/src/posthog_desktop_lifecycle.dart @@ -0,0 +1,200 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/widgets.dart'; + +import 'posthog_desktop_context.dart'; +import 'util/logging.dart'; + +/// What the Windows and Linux implementation drives from the application +/// lifecycle. A desktop app has no background state, so it counts as opened +/// when it becomes active and as backgrounded when it resigns active: +/// +/// - `Application Installed` / `Application Updated` when this launch's +/// version or build differs from the previous launch's; +/// - `Application Opened` / `Application Backgrounded` on those transitions; +/// - a flush of the queue whenever the app resigns active. +/// +/// Without a Flutter binding only the install/update check runs. +class DesktopAppLifecycle { + DesktopAppLifecycle({ + required WidgetsBinding? binding, + required String storageDirectory, + required String? version, + required String? build, + required bool captureEvents, + required void Function(String event, Map? properties) + capture, + required Future Function() flush, + }) : _binding = binding, + _version = version, + _build = build, + _captureEvents = captureEvents, + _capture = capture, + _flush = flush, + _versionFile = File( + '$storageDirectory${Platform.pathSeparator}posthog_app_version.json', + ) { + if (binding != null) { + _listener = AppLifecycleListener( + binding: binding, + onResume: _onResume, + onInactive: _onInactive, + ); + } + } + + final String? _version; + final String? _build; + final bool _captureEvents; + final void Function(String event, Map? properties) _capture; + final Future Function() _flush; + + final WidgetsBinding? _binding; + + /// Holds the version and build of the latest launch. Kept apart from the + /// SDK state so that reset() does not turn the next launch into an install. + final File _versionFile; + + AppLifecycleListener? _listener; + bool _isStarted = false; + bool _isFreshLaunch = true; + bool _isBackgrounded = true; + + /// Starts the lifecycle events: records this launch's version and captures + /// `Application Installed` or `Application Updated`. The app counts as + /// opened on its next activation, or right away with + /// [captureOpenedIfActive] when it is active already. + void start({bool captureOpenedIfActive = false}) { + if (_isStarted) return; + _isStarted = true; + _isFreshLaunch = true; + _isBackgrounded = true; + + // Recorded even when the events are off, so turning them on later does + // not report an app that was installed long ago. + final versionChange = _recordAppVersion(); + if (!_captureEvents) return; + + if (versionChange != null) { + _capture(versionChange.event, versionChange.properties); + } + if (captureOpenedIfActive && + _binding?.lifecycleState == AppLifecycleState.resumed) { + _captureOpened(); + } + } + + /// Stops the lifecycle events while the user is opted out. The flush on + /// resign active continues. + void stop() { + _isStarted = false; + } + + void dispose() { + stop(); + _listener?.dispose(); + _listener = null; + } + + void _onResume() { + if (_isStarted && _captureEvents) _captureOpened(); + } + + void _onInactive() { + if (_isStarted && _captureEvents) _captureBackgrounded(); + _flush().catchError((Object e) { + printIfDebug('[PostHog] Exception on flush: $e'); + }); + } + + void _captureOpened() { + if (!_isBackgrounded) return; + _isBackgrounded = false; + + final properties = {'from_background': !_isFreshLaunch}; + if (_isFreshLaunch) { + _isFreshLaunch = false; + _addVersion(properties); + } + _capture('Application Opened', properties); + } + + void _captureBackgrounded() { + if (_isBackgrounded) return; + _isBackgrounded = true; + _capture('Application Backgrounded', null); + } + + void _addVersion(Map properties) { + final version = _version; + final build = _build; + if (version != null) properties['version'] = version; + if (build != null) properties['build'] = parseBuildNumber(build); + } + + /// Records this launch's version and build and returns the event reporting + /// how they changed since the previous launch, if they did. + ({String event, Map properties})? _recordAppVersion() { + // Nothing to compare when the build recorded neither. + if (_version == null && _build == null) return null; + + Map? previous; + try { + previous = + jsonDecode(_versionFile.readAsStringSync()) as Map; + } on PathNotFoundException { + // The first launch with the SDK. + } catch (e) { + // An unknown previous version must not be reported as an install. + printIfDebug('[PostHog] Could not read the previous app version: $e'); + _writeAppVersion(_version, _build); + return null; + } + + final previousVersion = _stringOrNull(previous?['version']); + final previousBuild = _stringOrNull(previous?['build']); + // A value missing from this launch keeps the recorded one, so a later + // launch still compares against it. + _writeAppVersion(_version ?? previousVersion, _build ?? previousBuild); + + final properties = {}; + final String event; + if (previousVersion == null && previousBuild == null) { + event = 'Application Installed'; + } else { + // Builds are compared, and versions when the app has no build number. + final unchanged = _build != null + ? _build == previousBuild + : _version == previousVersion; + if (unchanged) return null; + + event = 'Application Updated'; + if (previousVersion != null) { + properties['previous_version'] = previousVersion; + } + if (previousBuild != null) { + properties['previous_build'] = parseBuildNumber(previousBuild); + } + } + _addVersion(properties); + return (event: event, properties: properties); + } + + void _writeAppVersion(String? version, String? build) { + try { + _versionFile.parent.createSync(recursive: true); + // Atomic replace: a crash mid-write must not lose the recorded version. + final tmp = File('${_versionFile.path}.tmp'); + tmp.writeAsStringSync( + jsonEncode({'version': version, 'build': build}), + flush: true, + ); + tmp.renameSync(_versionFile.path); + } catch (e) { + printIfDebug('[PostHog] Could not record the app version: $e'); + } + } + + static String? _stringOrNull(Object? value) => value is String ? value : null; +} diff --git a/posthog_flutter/lib/src/posthog_desktop_storage.dart b/posthog_flutter/lib/src/posthog_desktop_storage.dart new file mode 100644 index 00000000..d1a803be --- /dev/null +++ b/posthog_flutter/lib/src/posthog_desktop_storage.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'util/logging.dart'; + +/// Where the Windows and Linux implementation persists its state: queued +/// events, identity, consent and cached feature flags. +class DesktopStorage { + /// The directory of the app at [executable] in the user's application data + /// directory: `%APPDATA%\posthog\` on Windows, + /// `$XDG_DATA_HOME/posthog/` (or under + /// `~/.local/share`) on Linux, read from [environment]. + /// + /// The executable name identifies the app because it stays the same across + /// releases; renaming the executable starts over with an empty state. + static String appDirectory( + Map environment, { + required String executable, + }) { + String? base; + if (Platform.isWindows) { + base = environment['APPDATA'] ?? environment['LOCALAPPDATA']; + } else { + base = environment['XDG_DATA_HOME']; + if (base == null || base.isEmpty) { + final home = environment['HOME']; + if (home != null && home.isNotEmpty) { + base = '$home/.local/share'; + } + } + } + if (base == null || base.isEmpty) { + // FileStorage creates the directory itself; the fallback must stay + // scoped or it reopens the shared cross-project store. + base = Directory.systemTemp.path; + printIfDebug( + '[PostHog] No application data directory found; persisting events ' + 'under $base, which the OS may clear at any time.'); + } + + final sep = Platform.pathSeparator; + return '$base${sep}posthog$sep${_scope(_executableName(executable))}'; + } + + /// The directory of the project with [projectToken] in [appDirectory]: a + /// shared one would mix identity, consent and queued events between + /// projects. + static String projectDirectory(String appDirectory, String projectToken) => + '$appDirectory${Platform.pathSeparator}${_scope(projectToken)}'; + + /// The file name of [executable] without the `.exe` extension of Windows + /// executables. + static String _executableName(String executable) { + final name = executable.split(RegExp(r'[/\\]')).last; + return name.toLowerCase().endsWith('.exe') + ? name.substring(0, name.length - '.exe'.length) + : name; + } + + static String _scope(String value) { + final scope = value.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_'); + if (scope.isEmpty || scope == '.' || scope == '..') return 'default'; + return scope; + } +} diff --git a/posthog_flutter/lib/src/posthog_desktop_time_zone.dart b/posthog_flutter/lib/src/posthog_desktop_time_zone.dart new file mode 100644 index 00000000..330bbb0c --- /dev/null +++ b/posthog_flutter/lib/src/posthog_desktop_time_zone.dart @@ -0,0 +1,102 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'util/logging.dart'; + +/// The local IANA time zone, such as `Europe/Berlin`. +class DesktopTimeZone { + /// Reads `TZ`, then the system time zone. Returns null if neither identifies + /// one. + static String? read(Map environment) { + final fromVariable = fromTzVariable(environment['TZ']); + if (fromVariable != null) return fromVariable; + if (Platform.isWindows) { + try { + return _WindowsTimeZone.read(); + } catch (e) { + printIfDebug('[PostHog] Could not read the Windows time zone: $e'); + return null; + } + } + try { + return fromZoneInfoPath( + File('/etc/localtime').resolveSymbolicLinksSync(), + ); + } on FileSystemException { + return null; + } + } + + /// The zone a `TZ` value names: a zone name, optionally after a `:`, or + /// the path of a file in a zoneinfo directory. + /// + /// Null for a POSIX rule such as `CET-1CEST,M3.5.0,M10.5.0/3`, which names + /// no zone. + static String? fromTzVariable(String? value) { + if (value == null) return null; + final tz = value.startsWith(':') ? value.substring(1) : value; + return tz.startsWith('/') ? fromZoneInfoPath(tz) : _zoneName(tz); + } + + /// The zone of a file in a zoneinfo directory, such as + /// `/usr/share/zoneinfo/Europe/Berlin`. + static String? fromZoneInfoPath(String path) { + const directory = '/zoneinfo/'; + final index = path.indexOf(directory); + if (index < 0) return null; + return _zoneName(path.substring(index + directory.length)); + } + + static String? _zoneName(String value) { + // Copies of the database that differ in leap seconds, not in zone names. + final zone = value.replaceFirst(RegExp('^(posix|right)/'), ''); + return _zoneNamePattern.hasMatch(zone) ? zone : null; + } + + /// Zone names use no digits, which would make them read as POSIX rules, + /// except for the fixed offsets (`Etc/GMT+10`) and four legacy zones. + static final _zoneNamePattern = RegExp( + r'^([A-Za-z][A-Za-z._+-]*(/[A-Za-z._+-]+)*|(Etc/)?GMT[+-]?\d{1,2}|' + r'EST5EDT|CST6CDT|MST7MDT|PST8PDT)$', + ); +} + +abstract final class _WindowsTimeZone { + static String? read() { + const capacity = 256; + final memory = _allocate(capacity * 2 + sizeOf()); + if (memory.address == 0) return null; + try { + final result = memory.cast(); + final directoryLength = _getSystemDirectory(result, capacity); + if (directoryLength == 0 || directoryLength >= capacity) return null; + final directory = + String.fromCharCodes(result.asTypedList(directoryLength)); + // System ICU maintains Windows/IANA mappings, unlike a static table. + // https://learn.microsoft.com/windows/win32/intl/international-components-for-unicode--icu- + final getDefaultTimeZone = DynamicLibrary.open('$directory\\icu.dll') + .lookupFunction< + Int32 Function(Pointer, Int32, Pointer), + int Function(Pointer, int, Pointer)>( + 'ucal_getDefaultTimeZone', + ); + final status = (memory.cast() + capacity * 2).cast(); + status.value = 0; + final length = getDefaultTimeZone(result, capacity, status); + if (status.value > 0 || length <= 0 || length >= capacity) return null; + final zone = String.fromCharCodes(result.asTypedList(length)); + return zone == 'Etc/Unknown' ? null : zone; + } finally { + _free(memory); + } + } + + static final _getSystemDirectory = DynamicLibrary.open('kernel32.dll') + .lookupFunction, Uint32), + int Function(Pointer, int)>('GetSystemDirectoryW'); + static final _ole32 = DynamicLibrary.open('ole32.dll'); + static final _allocate = _ole32.lookupFunction Function(IntPtr), + Pointer Function(int)>('CoTaskMemAlloc'); + static final _free = _ole32.lookupFunction), + void Function(Pointer)>('CoTaskMemFree'); +} diff --git a/posthog_flutter/lib/src/posthog_desktop_version_resource.dart b/posthog_flutter/lib/src/posthog_desktop_version_resource.dart new file mode 100644 index 00000000..4bf0cfee --- /dev/null +++ b/posthog_flutter/lib/src/posthog_desktop_version_resource.dart @@ -0,0 +1,138 @@ +import 'dart:ffi'; + +/// Reads the version resource of a Windows executable: the VERSIONINFO block +/// a Flutter app declares in `windows/runner/Runner.rc`. +/// +/// Only dart:ffi is used, so the plugin does not add package:ffi or +/// package:win32 to the dependencies of every app for three API calls. +class WindowsVersionResource { + /// The `ProductName` and `ProductVersion` strings of [executable], null + /// where the resource does not carry them. + /// + /// Throws when the Windows API cannot be loaded. + static ({String? productName, String? productVersion}) read( + String executable, + ) { + final memory = _NativeMemory(); + try { + final path = memory.utf16(executable); + final size = _getFileVersionInfoSize(path, memory.allocate(4)); + if (size == 0) return _none; + final block = memory.allocate(size); + if (_getFileVersionInfo(path, 0, size, block) == 0) return _none; + + final value = memory.allocate>(sizeOf>()); + final length = memory.allocate(4); + bool query(String subBlock) => + _verQueryValue(block, memory.utf16(subBlock), value, length) != 0 && + length.value > 0; + + // The strings are stored per language and code page, which the runner + // template declares once: 0x0409 and 1252 name the table "040904e4". + if (!query(r'\VarFileInfo\Translation') || length.value < 4) { + return _none; + } + final translation = value.value.cast(); + final table = '\\StringFileInfo\\' + '${_hex4(translation[0])}${_hex4(translation[1])}'; + + String? string(String name) { + if (!query('$table\\$name')) return null; + final chars = value.value.cast().asTypedList(length.value); + final end = chars.indexOf(0); + final result = String.fromCharCodes(chars, 0, end < 0 ? null : end); + return result.isEmpty ? null : result; + } + + return ( + productName: string('ProductName'), + productVersion: string('ProductVersion'), + ); + } finally { + memory.releaseAll(); + } + } + + static const ({String? productName, String? productVersion}) _none = + (productName: null, productVersion: null); + + static String _hex4(int value) => value.toRadixString(16).padLeft(4, '0'); +} + +/// Native memory for [WindowsVersionResource.read], released all at once. +class _NativeMemory { + final _allocations = >[]; + + Pointer allocate(int byteCount) { + final pointer = _coTaskMemAlloc(byteCount); + if (pointer.address == 0) { + throw StateError('Could not allocate $byteCount bytes'); + } + _allocations.add(pointer); + return pointer.cast(); + } + + /// A NUL-terminated copy of [value] in UTF-16, the encoding of the Windows + /// API. + Pointer utf16(String value) { + final units = value.codeUnits; + final pointer = allocate((units.length + 1) * 2); + pointer.asTypedList(units.length + 1) + ..setAll(0, units) + ..last = 0; + return pointer; + } + + void releaseAll() { + _allocations + ..forEach(_coTaskMemFree) + ..clear(); + } +} + +// The API set resolves to the system implementation, never to a version.dll +// placed next to the executable. +final _versionApi = DynamicLibrary.open('api-ms-win-core-version-l1-1-0.dll'); + +final _getFileVersionInfoSize = _versionApi.lookupFunction< + Uint32 Function(Pointer filename, Pointer handle), + int Function( + Pointer filename, + Pointer handle, + )>('GetFileVersionInfoSizeW'); + +final _getFileVersionInfo = _versionApi.lookupFunction< + Int32 Function( + Pointer filename, + Uint32 handle, + Uint32 length, + Pointer data, + ), + int Function( + Pointer filename, + int handle, + int length, + Pointer data, + )>('GetFileVersionInfoW'); + +final _verQueryValue = _versionApi.lookupFunction< + Int32 Function( + Pointer block, + Pointer subBlock, + Pointer> buffer, + Pointer length, + ), + int Function( + Pointer block, + Pointer subBlock, + Pointer> buffer, + Pointer length, + )>('VerQueryValueW'); + +final _ole32 = DynamicLibrary.open('ole32.dll'); + +final _coTaskMemAlloc = _ole32.lookupFunction Function(Size size), + Pointer Function(int size)>('CoTaskMemAlloc'); + +final _coTaskMemFree = _ole32.lookupFunction block), + void Function(Pointer block)>('CoTaskMemFree'); diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart new file mode 100644 index 00000000..e97f5dfb --- /dev/null +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -0,0 +1,626 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import 'core/file_storage.dart'; +import 'error_tracking/dart_exception_processor.dart'; +import 'feature_flag_result.dart'; +import 'logs/posthog_log_severity.dart'; +import 'posthog_config.dart'; +import 'posthog_constants.dart'; +import 'posthog_desktop_app_info.dart'; +import 'posthog_desktop_client.dart'; +import 'posthog_desktop_context.dart'; +import 'posthog_desktop_exception_steps.dart'; +import 'posthog_desktop_lifecycle.dart'; +import 'posthog_desktop_storage.dart'; +import 'posthog_event.dart'; +import 'posthog_flutter_platform_interface.dart'; +import 'util/logging.dart'; +import 'utils/before_send.dart'; +import 'utils/capture_utils.dart'; +import 'utils/property_normalizer.dart'; + +/// The Windows and Linux implementation, built on the pure-Dart +/// [DesktopPostHog] client. +/// +/// Every method is wrapped in a guard: an error inside the SDK is logged in +/// debug builds and never thrown into the app. Calls made before setup() or +/// after close() are ignored, with a debug warning. +class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { + /// Creates the implementation for an app that keeps its state in + /// [appDirectory], whose build recorded [appInfo], and that runs in the + /// IANA time zone [timezone]. + PosthogFlutterDesktop({ + required String appDirectory, + required DesktopAppInfo appInfo, + required String? timezone, + }) : _appDirectory = appDirectory, + _appInfo = appInfo, + _timezone = timezone; + + /// See [DesktopStorage.appDirectory]. + final String _appDirectory; + + final DesktopAppInfo _appInfo; + + final String? _timezone; + + DesktopPostHog? _client; + + /// The configuration of the latest setup(), read by the Dart-side hooks: + /// beforeSend, onFeatureFlags and exception processing. + PostHogConfig? _config; + + void Function()? _featureFlagsUnsubscribe; + + /// Null while exception steps are disabled or no client is set up. + ExceptionStepsBuffer? _exceptionSteps; + + DesktopAppLifecycle? _appLifecycle; + + /// The client that [op] goes to. There is none before setup() and after + /// close(), and the call is then dropped. + DesktopPostHog? _clientFor(String op) { + final client = _client; + if (client == null) { + printIfDebug('[PostHog] $op ignored: PostHog is not set up or was ' + 'closed.'); + } + return client; + } + + Future _guard( + String op, + FutureOr Function(DesktopPostHog client) fn, + ) => + _guardWith(op, null, fn); + + /// Runs [op] on the client, or returns [fallback] when there is no client + /// or the SDK fails. + Future _guardWith( + String op, + T fallback, + FutureOr Function(DesktopPostHog client) fn, + ) async { + try { + final client = _clientFor(op); + return client == null ? fallback : await fn(client); + } catch (e) { + printIfDebug('[PostHog] Exception on $op: $e'); + return fallback; + } + } + + @override + Future setup(PostHogConfig config) async { + try { + _setup(config); + } catch (e) { + printIfDebug('[PostHog] Exception on setup: $e'); + } + } + + void _setup(PostHogConfig config) { + // The Dart-side hooks follow every setup(), while a running client keeps + // its configuration until close(). + _config = config; + if (_client != null) { + printIfDebug('[PostHog] Setup called despite already being setup!'); + return; + } + // Created synchronously, so calls made right after an unawaited setup() + // already reach the client. + _startClient(config); + } + + void _startClient(PostHogConfig config) { + final storageDirectory = + DesktopStorage.projectDirectory(_appDirectory, config.projectToken); + final client = DesktopPostHog( + config, + staticContext: collectDesktopContext(_appInfo), + timezone: _timezone, + storage: FileStorage(storageDirectory), + ); + _client = client; + _featureFlagsUnsubscribe = + client.onFeatureFlags(() => _config?.onFeatureFlags?.call()); + + final stepsConfig = config.errorTrackingConfig.exceptionSteps; + _exceptionSteps = stepsConfig.enabled + ? ExceptionStepsBuffer(maxBytes: stepsConfig.maxBytes) + : null; + + final appLifecycle = _appLifecycle = DesktopAppLifecycle( + binding: _widgetsBinding(), + storageDirectory: storageDirectory, + version: _appInfo.version, + build: _appInfo.build, + captureEvents: config.captureApplicationLifecycleEvents, + // Straight to the client: lifecycle events do not pass through the + // beforeSend callbacks, as PostHogConfig.beforeSend documents. + capture: (event, properties) => + client.capture(event, properties: properties), + flush: client.flush, + ); + // While opted out, enable() starts it. An app set up after its window + // became active has missed that activation, so it is reported as opened. + if (!client.optedOut) appLifecycle.start(captureOpenedIfActive: true); + } + + static WidgetsBinding? _widgetsBinding() { + try { + return WidgetsBinding.instance; + } catch (_) { + printIfDebug( + '[PostHog] Call WidgetsFlutterBinding.ensureInitialized() before ' + 'setup() to capture Application Opened and Backgrounded events.', + ); + return null; + } + } + + @override + Future identify({ + required String userId, + Map? userProperties, + Map? userPropertiesSetOnce, + }) => + _guard('identify', (client) { + client.identify( + userId, + userProperties: _normalize(userProperties), + userPropertiesSetOnce: _normalize(userPropertiesSetOnce), + ); + }); + + @override + Future setPersonProperties({ + Map? userPropertiesToSet, + Map? userPropertiesToSetOnce, + }) => + _guard('setPersonProperties', (client) { + client.setPersonProperties( + userPropertiesToSet: _normalize(userPropertiesToSet), + userPropertiesToSetOnce: _normalize(userPropertiesToSetOnce), + ); + }); + + @override + Future capture({ + required String eventName, + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + }) => + _guard('capture', (_) async { + if (!_hasName(eventName)) return; + + final processed = await _runBeforeSend( + eventName, + properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ); + if (processed == null) { + printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); + return; + } + // A callback may have renamed the event. + if (!_hasName(processed.event)) return; + + // Resolved after the callbacks, which may be async: a client closed + // meanwhile must not write to the on-disk state again. + final client = _clientFor('capture'); + if (client == null) return; + client.capture( + processed.event, + properties: _withExceptionSteps( + processed.event, + _mergeUserProps( + processed.properties, + processed.userProperties, + processed.userPropertiesSetOnce, + ), + ), + ); + }); + + @override + Future screen({ + required String screenName, + Map? properties, + }) => + _guard('screen', (client) async { + // Opted out, nothing happens: no callback runs, and the screen does + // not become the current one. + if (client.optedOut) return; + + final processed = await _runBeforeSend( + PostHogEventName.screen, + { + ...?properties, + PostHogPropertyName.screenName: screenName, + }, + ); + if (processed == null) { + printIfDebug( + '[PostHog] Screen event dropped by beforeSend: $screenName'); + return; + } + + // A renamed event is no longer a screen view, so it is captured as a + // regular event. + if (processed.event != PostHogEventName.screen) { + await capture( + eventName: processed.event, + properties: processed.properties, + ); + return; + } + + // Re-added after the callbacks, so one that rebuilds the property map + // cannot drop the screen name. + final finalScreenName = + processed.properties?[PostHogPropertyName.screenName] as String? ?? + screenName; + if (finalScreenName.isEmpty) { + printIfDebug('[PostHog] Screen event dropped: empty screen name'); + return; + } + + // Resolved again after the callbacks, as in capture(). + final current = _clientFor('screen'); + if (current == null) return; + // Later events that set no `$screen_name` of their own carry this one. + current.registerForSession( + {PostHogPropertyName.screenName: finalScreenName}); + current.capture( + PostHogEventName.screen, + properties: { + ...?_normalize(processed.properties), + PostHogPropertyName.screenName: finalScreenName, + }, + ); + }); + + /// Structured logs are not supported on the desktop implementation. + @override + Future captureLog({ + required String body, + PostHogLogSeverity level = PostHogLogSeverity.info, + Map? attributes, + String? traceId, + String? spanId, + int? traceFlags, + }) async {} + + @override + Future registerPushNotificationToken( + String deviceToken, { + String? appId, + }) async { + // Push notifications are not supported on desktop. + } + + @override + Future unregisterPushNotificationToken() async { + // Push notifications are not supported on desktop. + } + + @override + Future capturePushNotificationOpened({ + String? title, + String? subtitle, + String? body, + Map? payload, + String? action, + }) async { + // Push notifications are not supported on desktop. + } + + @override + Future alias({required String alias}) => + _guard('alias', (client) => client.alias(alias)); + + @override + Future getDistinctId() => + _guardWith('getDistinctId', '', (client) => client.getDistinctId()); + + @override + Future reset() => _guard('reset', (client) => client.reset()); + + @override + Future disable() => _guard('disable', (client) { + client.optOut(); + _appLifecycle?.stop(); + }); + + @override + Future enable() => _guard('enable', (client) { + client.optIn(); + // Opting in is not an app open: an active app is reported as opened + // on its next activation. + _appLifecycle?.start(); + }); + + /// Reports opted out without a client: before setup() and after close(). + @override + Future isOptOut() => + _guardWith('isOptOut', true, (client) => client.optedOut); + + @override + Future debug(bool enabled) => + _guard('debug', (client) => client.debug(enabled)); + + @override + Future register(String key, Object value) => _guard('register', + (client) => client.register(PropertyNormalizer.normalize({key: value}))); + + @override + Future unregister(String key) => + _guard('unregister', (client) => client.unregister(key)); + + @override + Future isFeatureEnabled(String key) => _guardWith('isFeatureEnabled', + false, (client) => client.isFeatureEnabled(key) ?? false); + + @override + Future reloadFeatureFlags() => _guard( + 'reloadFeatureFlags', (client) => client.reloadFeatureFlagsAsync()); + + @override + Future setPersonPropertiesForFlags( + Map userProperties, + ) => + _guard('setPersonPropertiesForFlags', (client) { + client.setPersonPropertiesForFlags( + _normalize(userProperties) ?? const {}, + ); + }); + + @override + Future resetPersonPropertiesForFlags() => _guard( + 'resetPersonPropertiesForFlags', + (client) => client.resetPersonPropertiesForFlags()); + + @override + Future setGroupPropertiesForFlags( + String groupType, + Map groupProperties, + ) => + _guard('setGroupPropertiesForFlags', (client) { + final normalized = _normalize(groupProperties) ?? const {}; + client.setGroupPropertiesForFlags({groupType: normalized}); + }); + + @override + Future resetGroupPropertiesForFlags({String? groupType}) => _guard( + 'resetGroupPropertiesForFlags', + (client) => client.resetGroupPropertiesForFlags(groupType: groupType)); + + @override + Future group({ + required String groupType, + required String groupKey, + Map? groupProperties, + }) => + _guard('group', (client) { + client.group( + groupType, + groupKey, + groupProperties: _normalize(groupProperties), + ); + }); + + @override + Future getFeatureFlag({required String key}) => _guardWith( + 'getFeatureFlag', null, (client) => client.getFeatureFlag(key)); + + @override + Future getFeatureFlagPayload({required String key}) => _guardWith( + 'getFeatureFlagPayload', + null, + (client) => client.getFeatureFlagResult(key, sendEvent: false)?.payload); + + @override + Future getFeatureFlagResult({ + required String key, + bool sendEvent = true, + }) => + _guardWith('getFeatureFlagResult', null, + (client) => client.getFeatureFlagResult(key, sendEvent: sendEvent)); + + @override + Future flush() => _guard('flush', (client) => client.flush()); + + @override + Future captureException({ + required Object error, + StackTrace? stackTrace, + Map? properties, + }) => + _guard('captureException', (_) async { + final exceptionProps = DartExceptionProcessor.processException( + error: error, + stackTrace: stackTrace, + properties: properties, + inAppIncludes: _config?.errorTrackingConfig.inAppIncludes, + inAppExcludes: _config?.errorTrackingConfig.inAppExcludes, + inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, + ); + + final processed = await _runBeforeSend( + PostHogEventName.exception, + exceptionProps.cast(), + ); + if (processed == null) { + printIfDebug( + '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', + ); + return; + } + + // A renamed event is no longer an exception, so it is captured as a + // regular event. + if (processed.event != PostHogEventName.exception) { + await capture( + eventName: processed.event, + properties: processed.properties, + ); + return; + } + + final client = _clientFor('captureException'); + if (client == null) return; + client.capture( + PostHogEventName.exception, + properties: _withExceptionSteps( + PostHogEventName.exception, + _normalize(processed.properties), + ), + ); + }); + + @override + Future addExceptionStep( + String message, { + Map? properties, + }) => + _guard('addExceptionStep', (client) { + // Nothing is recorded while opted out: the steps would reach an + // exception captured after opting in. + if (client.optedOut) return; + _exceptionSteps?.add(message, properties: properties); + }); + + @override + Future close() => _guard('close', (client) { + _client = null; + _exceptionSteps = null; + _appLifecycle?.dispose(); + _appLifecycle = null; + _featureFlagsUnsubscribe?.call(); + _featureFlagsUnsubscribe = null; + + // Nothing is sent on close: queued events stay on disk and go out + // with the next client. + client.close(); + }); + + @override + Future getSessionId() => + _guardWith('getSessionId', null, (client) => client.getSessionId()); + + @override + Future openUrl(String url) async { + // Surveys/native UI only - not supported on desktop. + } + + @override + Future showSurvey(Map survey) async { + // Surveys are not supported on desktop. + } + + @override + Future startSessionRecording({bool resumeCurrent = true}) async { + // Session replay is not supported on desktop. + } + + @override + Future stopSessionRecording() async { + // Session replay is not supported on desktop. + } + + @override + Future isSessionReplayActive() async => false; + + /// Applies the beforeSend callbacks to an event in order. Running them here + /// rather than in the client keeps SDK-internal events ($identify, + /// $feature_flag_called, ...) and the properties the SDK adds out of the + /// callbacks' sight. + /// + /// Returns the possibly modified event, or null if any callback drops it + /// or throws. + Future _runBeforeSend( + String eventName, + Map? properties, { + Map? userProperties, + Map? userPropertiesSetOnce, + }) async { + var event = PostHogEvent( + event: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ); + + final callbacks = _config?.beforeSend ?? const []; + for (final callback in callbacks) { + try { + final result = await runBeforeSend(callback, event); + if (result == null) return null; + event = result; + } catch (e) { + // A callback that fails may be the one scrubbing sensitive data, so + // neither the original nor a partially processed event is sent. + printIfDebug( + '[PostHog] Warning: beforeSend callback threw an exception; dropping event: $e', + ); + return null; + } + } + return event; + } + + /// Whether [event] has a name; an event without one is dropped. + static bool _hasName(String event) { + if (event.isNotEmpty) return true; + printIfDebug('[PostHog] Event dropped: empty event name'); + return false; + } + + /// Adds the recorded exception steps to an `$exception` event that does not + /// set its own. + Map? _withExceptionSteps( + String event, + Map? properties, + ) { + final steps = _exceptionSteps?.steps; + if (event != PostHogEventName.exception || steps == null || steps.isEmpty) { + return properties; + } + return {r'$exception_steps': steps, ...?properties}; + } + + Map? _mergeUserProps( + Map? properties, + Map? userProperties, + Map? userPropertiesSetOnce, + ) { + // Inline $set/$set_once in properties are legacy but still honored, with + // the explicit parameters winning per key. + final extracted = CaptureUtils.extractUserProperties( + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ); + + final set = _normalize(extracted.userProperties); + final setOnce = _normalize(extracted.userPropertiesSetOnce); + final merged = { + ...?_normalize(extracted.properties), + if (set != null && set.isNotEmpty) r'$set': set, + if (setOnce != null && setOnce.isNotEmpty) r'$set_once': setOnce, + }; + return merged.isNotEmpty ? merged : null; + } + + /// Converts the values jsonEncode cannot handle, which storage and /batch/ + /// payloads need. + Map? _normalize(Map? properties) { + if (properties == null || properties.isEmpty) return properties; + return Map.from(PropertyNormalizer.normalize(properties)); + } +} diff --git a/posthog_flutter/lib/src/posthog_widget.dart b/posthog_flutter/lib/src/posthog_widget.dart index b302c660..ad7a2b17 100644 --- a/posthog_flutter/lib/src/posthog_widget.dart +++ b/posthog_flutter/lib/src/posthog_widget.dart @@ -1,4 +1,5 @@ -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; // The replay capturer defines its own ImageInfo; hide Flutter's. import 'package:flutter/material.dart' hide ImageInfo; import 'package:posthog_flutter/posthog_flutter.dart'; @@ -67,9 +68,7 @@ class PostHogWidgetState extends State { return; } - // On web, session replay is recorded by posthog-js; this pipeline has no - // consumer there, so every snapshot it produces is discarded. - if (!kIsWeb && config.sessionReplay) { + if (_capturesSnapshots && config.sessionReplay) { _initComponents(config); _changeDetector?.start(); } @@ -192,6 +191,14 @@ class PostHogWidgetState extends State { (_screenshotCapturer?.sessionStillCurrent(imageInfo) ?? false); } + /// On web, session replay is recorded by posthog-js, and Windows and Linux + /// have no native SDK to record it: this pipeline has no consumer there, so + /// every snapshot it produces would be discarded. + static bool get _capturesSnapshots => + !kIsWeb && + defaultTargetPlatform != TargetPlatform.windows && + defaultTargetPlatform != TargetPlatform.linux; + void _initComponents(PostHogConfig config) { _screenshotCapturer = ScreenshotCapturer( config, @@ -217,7 +224,7 @@ class PostHogWidgetState extends State { } void _startRecording() { - if (kIsWeb) { + if (!_capturesSnapshots) { return; } diff --git a/posthog_flutter/pubspec.yaml b/posthog_flutter/pubspec.yaml index fccda2b4..29b5857a 100644 --- a/posthog_flutter/pubspec.yaml +++ b/posthog_flutter/pubspec.yaml @@ -1,5 +1,5 @@ name: posthog_flutter -description: Flutter implementation of PostHog client for iOS, Android and Web +description: Flutter implementation of PostHog client for iOS, Android, macOS, Windows, Linux and Web version: 5.50.5 homepage: https://www.posthog.com repository: https://github.com/posthog/posthog-flutter @@ -25,6 +25,7 @@ dependencies: dev_dependencies: dart_apitool: ^0.23.2 + fake_async: ^1.3.1 flutter_lints: ^6.0.0 flutter_test: sdk: flutter @@ -58,6 +59,12 @@ flutter: web: pluginClass: PosthogFlutterWeb fileName: posthog_flutter_web.dart + linux: + dartPluginClass: PosthogFlutterDart + dartFileName: posthog_flutter_dart.dart + windows: + dartPluginClass: PosthogFlutterDart + dartFileName: posthog_flutter_dart.dart # To add assets to your plugin package, add an assets section, like this: # assets: diff --git a/posthog_flutter/test/core/bootstrap_test.dart b/posthog_flutter/test/core/bootstrap_test.dart new file mode 100644 index 00000000..a40e7a9b --- /dev/null +++ b/posthog_flutter/test/core/bootstrap_test.dart @@ -0,0 +1,306 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/file_storage.dart'; +import 'package:posthog_flutter/src/core/persistence.dart'; +import 'package:posthog_flutter/src/posthog_config.dart'; +import 'package:posthog_flutter/src/posthog_desktop_client.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + late LocalPostHogServer server; + late Directory dir; + + setUp(() async { + server = await LocalPostHogServer.start(); + dir = tempDirectory(); + }); + + /// A launch of the app with [bootstrap]: its client, and the storage the + /// client keeps its state in. + (DesktopPostHog, FileStorage) launch([PostHogBootstrapConfig? bootstrap]) { + final storage = FileStorage(dir.path); + final client = testClient(server, + config: testConfig(bootstrap: bootstrap), storage: storage); + return (client, storage); + } + + group('Bootstrapped identity', () { + test('seeds the anonymous id on a fresh install', () { + final (client, storage) = + launch(const PostHogBootstrapConfig(distinctId: 'anon-abc')); + + expect(client.getDistinctId(), 'anon-abc'); + expect(_isIdentified(storage), isFalse); + client.identify('user-1'); + expect(queuedPropsOf(storage, r'$identify')[r'$anon_distinct_id'], + 'anon-abc'); + }); + + test('seeds an identified user, but not as the device id', () async { + final (client, storage) = launch(const PostHogBootstrapConfig( + distinctId: 'user-123', isIdentifiedId: true)); + + await client.reloadFeatureFlagsAsync(); + + expect(client.getDistinctId(), 'user-123'); + expect(_isIdentified(storage), isTrue); + expect(server.flagsRequests.last.body[r'$device_id'], isNot('user-123'), + reason: 'device-level bucketing must never use the person id'); + expect(getQueue(storage), isEmpty, + reason: 'a fresh install has no anonymous history to link'); + }); + + test('never replaces a stored anonymous id', () { + final (earlier, _) = launch(); + final anonymousId = earlier.getDistinctId(); + earlier.close(); + + final (client, _) = + launch(const PostHogBootstrapConfig(distinctId: 'anon-new')); + + expect(client.getDistinctId(), anonymousId); + }); + + test('an identified bootstrap merges a stored anonymous user', () { + final (earlier, _) = launch(); + final anonymousId = earlier.getDistinctId(); + earlier.close(); + + final (client, storage) = launch(const PostHogBootstrapConfig( + distinctId: 'user-123', isIdentifiedId: true)); + + expect(client.getDistinctId(), 'user-123'); + final identify = queuedPropsOf(storage, r'$identify'); + expect(identify[r'$anon_distinct_id'], anonymousId); + }); + + test('an identified bootstrap keeps a different identified user', () { + final (earlier, _) = launch(); + earlier + ..identify('user-existing') + ..close(); + + final (client, storage) = launch(const PostHogBootstrapConfig( + distinctId: 'user-123', isIdentifiedId: true)); + + expect(client.getDistinctId(), 'user-existing'); + expect(queuedEvents(storage), [r'$identify']); + }); + + test('an identified bootstrap of the stored anonymous id sends nothing', + () { + final (earlier, _) = launch(); + final anonymousId = earlier.getDistinctId(); + earlier.close(); + + final (client, storage) = launch(PostHogBootstrapConfig( + distinctId: anonymousId, isIdentifiedId: true)); + + expect(client.getDistinctId(), anonymousId); + expect(_isIdentified(storage), isTrue); + expect(getQueue(storage), isEmpty); + }); + + test('an identified bootstrap is reconciled while opted out, silently', () { + final (earlier, _) = launch(); + earlier + ..getDistinctId() + ..optOut() + ..close(); + + final (client, storage) = launch(const PostHogBootstrapConfig( + distinctId: 'user-123', isIdentifiedId: true)); + + expect(client.getDistinctId(), 'user-123'); + expect(_isIdentified(storage), isTrue, + reason: 'opting out stops events, not the local identity'); + expect(getQueue(storage), isEmpty); + }); + }); + + group('Bootstrapped feature flags', () { + test('are served before the first flags response', () { + final (client, _) = launch(const PostHogBootstrapConfig(featureFlags: { + 'beta-ui': true, + 'checkout': 'variant-a', + }, featureFlagPayloads: { + 'checkout': {'color': 'blue'}, + })); + + expect(client.getFeatureFlag('beta-ui'), isTrue); + expect(client.getFeatureFlag('checkout'), 'variant-a'); + expect(client.getFeatureFlagResult('checkout', sendEvent: false)?.payload, + {'color': 'blue'}); + }); + + test('are not served when disabled, nor are their payloads', () { + final (client, _) = launch(const PostHogBootstrapConfig( + featureFlags: {'enabled': true, 'disabled': false, 'empty': ''}, + featureFlagPayloads: {'disabled': 'hidden'})); + + expect(client.getFeatureFlag('enabled'), isTrue); + expect(client.getFeatureFlag('disabled'), isNull); + expect(client.getFeatureFlag('empty'), isNull); + expect(client.getFeatureFlagResult('disabled', sendEvent: false)?.payload, + isNull); + }); + + test('replace flags stored by an earlier session', () async { + server.respond = (_) => _flagsResponse({ + 'checkout': _flag('checkout', enabled: false), + 'other': _flag('other'), + }); + final (earlier, _) = launch(); + await earlier.reloadFeatureFlagsAsync(); + earlier.close(); + + final (client, _) = launch( + const PostHogBootstrapConfig(featureFlags: {'checkout': true})); + + expect(client.getFeatureFlag('checkout'), isTrue); + expect(client.getFeatureFlag('other'), isNull); + }); + + test('are replaced by a complete flags response', () async { + final (client, _) = launch(const PostHogBootstrapConfig(featureFlags: { + 'beta-ui': 'variant-a', + 'legacy': true + }, featureFlagPayloads: { + 'beta-ui': {'color': 'blue'}, + })); + server.respond = (_) => + _flagsResponse({'beta-ui': _flag('beta-ui', variant: 'variant-b')}); + + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('beta-ui'), 'variant-b'); + expect(client.getFeatureFlagResult('beta-ui', sendEvent: false)?.payload, + isNull); + expect(client.getFeatureFlag('legacy'), isNull); + }); + + test('keep what an errored flags response did not compute', () async { + final (client, _) = launch(const PostHogBootstrapConfig( + featureFlags: {'beta-ui': true, 'legacy': true})); + server.respond = (_) => _flagsResponse( + {'beta-ui': _flag('beta-ui', enabled: false)}, + errorsWhileComputingFlags: true); + + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('beta-ui'), isFalse); + expect(client.getFeatureFlag('legacy'), isTrue); + }); + + test('are dropped by reset', () async { + final (client, _) = + launch(const PostHogBootstrapConfig(featureFlags: {'legacy': true})); + server.respond = (_) => _flagsResponse({}); + + client.reset(); + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('legacy'), isNull); + }); + + test('notify flags listeners right after setup', () async { + final (client, _) = + launch(const PostHogBootstrapConfig(featureFlags: {'beta-ui': true})); + final loaded = Completer(); + client.onFeatureFlags(loaded.complete); + + await loaded.future; + + expect(client.getFeatureFlag('beta-ui'), isTrue); + await client.reloadFeatureFlagsAsync(); + expect(server.flagsRequests, hasLength(1), + reason: 'the bootstrapped flags need no request of their own'); + }); + + test(r'are reported on $feature_flag_called until flags load', () async { + final (client, storage) = + launch(const PostHogBootstrapConfig(featureFlags: { + 'beta-ui': 'variant-a', + }, featureFlagPayloads: { + 'beta-ui': {'color': 'blue'}, + })); + + client.getFeatureFlag('beta-ui'); + server.respond = (_) => + _flagsResponse({'beta-ui': _flag('beta-ui', variant: 'variant-b')}); + await client.reloadFeatureFlagsAsync(); + client.getFeatureFlag('beta-ui'); + + final calls = [ + for (final message in getQueue(storage)) + if (message['event'] == r'$feature_flag_called') + message['properties']! as Map, + ]; + expect(calls, hasLength(2)); + expect(calls[0][r'$feature_flag_response'], 'variant-a'); + expect(calls[0][r'$feature_flag_bootstrapped_response'], 'variant-a'); + expect( + calls[0][r'$feature_flag_bootstrapped_payload'], {'color': 'blue'}); + expect(calls[0][r'$used_bootstrap_value'], isTrue); + expect(calls[1][r'$feature_flag_response'], 'variant-b'); + expect(calls[1][r'$used_bootstrap_value'], isFalse); + }); + + test(r'are reported unused after a quota-limited flags response', () async { + final (client, storage) = + launch(const PostHogBootstrapConfig(featureFlags: {'beta-ui': true})); + server.respond = + (_) => _flagsResponse({}, quotaLimited: ['feature_flags']); + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('beta-ui'), isTrue, + reason: 'cached flags keep serving under the quota limit'); + + final props = queuedPropsOf(storage, r'$feature_flag_called'); + expect(props[r'$feature_flag_bootstrapped_response'], isTrue); + expect(props[r'$used_bootstrap_value'], isFalse, + reason: 'PostHog answered the /flags request'); + }); + + test(r'are not reported for flags that were not bootstrapped', () async { + final (client, storage) = + launch(const PostHogBootstrapConfig(featureFlags: {'beta-ui': true})); + server.respond = (_) => _flagsResponse({'other': _flag('other')}); + await client.reloadFeatureFlagsAsync(); + + client.getFeatureFlag('other'); + + final props = queuedPropsOf(storage, r'$feature_flag_called'); + expect(props.containsKey(r'$used_bootstrap_value'), isFalse); + expect( + props.containsKey(r'$feature_flag_bootstrapped_response'), isFalse); + }); + }); +} + +bool _isIdentified(FileStorage storage) => + storage.getProperty(PostHogPersistedProperty.personMode) == + 'identified'; + +Map _flag(String key, + {bool enabled = true, String? variant}) => + { + 'key': key, + 'enabled': enabled, + if (variant != null) 'variant': variant, + }; + +PostHogResponse _flagsResponse( + Map flags, { + bool errorsWhileComputingFlags = false, + List? quotaLimited, +}) => + PostHogResponse.json({ + 'flags': flags, + 'errorsWhileComputingFlags': errorsWhileComputingFlags, + if (quotaLimited != null) 'quotaLimited': quotaLimited, + }); diff --git a/posthog_flutter/test/core/compression_test.dart b/posthog_flutter/test/core/compression_test.dart new file mode 100644 index 00000000..bae48946 --- /dev/null +++ b/posthog_flutter/test/core/compression_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_config.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + for (final compression in PostHogCompression.values) { + test('${compression.name} applies to batches and flags', () async { + final server = await LocalPostHogServer.start(); + final client = + testClient(server, config: testConfig()..compression = compression); + + client.capture('desktop_event', properties: {'source': 'test'}); + await client.flush(); + await client.reloadFeatureFlagsAsync(); + + expect(server.batchRequests.single.eventNames, ['desktop_event']); + expect(server.flagsRequests, hasLength(1)); + for (final request in server.requests) { + expect(request.headers['content-type'], 'application/json'); + expect(request.headers['content-encoding'], + compression == PostHogCompression.gzip ? 'gzip' : isNull); + expect(request.body['api_key'] ?? request.body['token'], 'k'); + } + }); + } +} diff --git a/posthog_flutter/test/core/context_properties_test.dart b/posthog_flutter/test/core/context_properties_test.dart new file mode 100644 index 00000000..2586102f --- /dev/null +++ b/posthog_flutter/test/core/context_properties_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_flutter_version.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + late LocalPostHogServer server; + + setUp(() async { + server = await LocalPostHogServer.start(); + }); + + group('Context properties', () { + test('attached to every captured event', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage, context: _context); + + client.capture('event', properties: {'foo': 'bar'}); + + final props = queuedProps(storage, 0); + expect(props[r'$os_name'], 'TestOS'); + expect(props[r'$app_version'], '1.0.0'); + expect(props['foo'], 'bar'); + }); + + test('win over super properties and event properties', () { + final storage = tempStorage(); + final client = testClient(server, + storage: storage, context: _context, timezone: 'Europe/Berlin'); + + client.register({r'$os_name': 'FromSuperProps'}); + client.capture('event', properties: { + r'$app_version': '2.0.0', + r'$timezone': 'UTC', + r'$lib': 'custom', + }); + + final props = queuedProps(storage, 0); + expect(props[r'$os_name'], 'TestOS'); + expect(props[r'$app_version'], '1.0.0'); + expect(props[r'$timezone'], 'Europe/Berlin'); + expect(props[r'$lib'], postHogFlutterSdkName); + }); + + test('absent by default', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + + client.capture('event'); + + expect(queuedProps(storage, 0).containsKey(r'$os_name'), isFalse); + expect(queuedProps(storage, 0).containsKey(r'$timezone'), isFalse); + }); + + test(r'carry the $timezone a host provides', () { + final storage = tempStorage(); + final client = + testClient(server, storage: storage, timezone: 'Europe/Berlin'); + + client.capture('event'); + + expect(queuedProps(storage, 0)[r'$timezone'], 'Europe/Berlin'); + }); + }); +} + +const _context = { + r'$os_name': 'TestOS', + r'$app_version': '1.0.0', +}; diff --git a/posthog_flutter/test/core/desktop_parity_regressions_test.dart b/posthog_flutter/test/core/desktop_parity_regressions_test.dart new file mode 100644 index 00000000..2728cd4d --- /dev/null +++ b/posthog_flutter/test/core/desktop_parity_regressions_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/file_storage.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + late LocalPostHogServer server; + + setUp(() async { + server = await LocalPostHogServer.start(); + }); + + for (final invalidGroups in [ + 'legacy', + 42, + ['legacy'], + ]) { + test('invalid groups $invalidGroups do not block capture and flags', + () async { + final directory = tempDirectory(); + final first = testClient(server, storage: FileStorage(directory.path)); + first.register({r'$groups': invalidGroups, 'plan': 'pro'}); + first.close(); + final client = testClient(server, storage: FileStorage(directory.path)); + + client.capture('purchase'); + await client.reloadFeatureFlagsAsync(); + await client.flush(); + + final properties = server.events.single['properties']! as Map; + expect(properties['plan'], 'pro'); + expect(properties[r'$process_person_profile'], isFalse); + expect(server.flagsRequests.last.body['groups'], isEmpty); + + client.group('company', 'acme'); + await client.reloadFeatureFlagsAsync(); + expect(server.flagsRequests.last.body['groups'], {'company': 'acme'}); + }); + } + + test('valid memberships in an old store survive an invalid neighboring key', + () async { + final directory = tempDirectory(); + final first = testClient(server, storage: FileStorage(directory.path)); + first.register({ + r'$groups': {'company': 'acme', 'broken': 42}, + }); + first.close(); + final storage = FileStorage(directory.path); + final client = testClient(server, storage: storage); + + client.capture('purchase'); + await client.reloadFeatureFlagsAsync(); + + expect(queuedProps(storage, 0)[r'$process_person_profile'], isTrue); + expect(server.flagsRequests.last.body['groups'], {'company': 'acme'}); + }); + + test('a caller session ID applies only to its event', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + final internalSession = client.getSessionId(); + + client + .capture('external', properties: {r'$session_id': 'external-session'}); + client.capture('internal'); + + expect(queuedProps(storage, 0)[r'$session_id'], 'external-session'); + expect(queuedProps(storage, 1)[r'$session_id'], internalSession); + expect(client.getSessionId(), internalSession); + }); + + for (final invalidSession in ['', 42]) { + test('invalid session ID $invalidSession is replaced with the current ID', + () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + client.capture('purchase', properties: {r'$session_id': invalidSession}); + expect(queuedProps(storage, 0)[r'$session_id'], client.getSessionId()); + }); + } + + for (final property in [r'$set', r'$set_once']) { + test('a direct $property updates the marker for the latest person update', + () async { + final client = testClient(server); + void setPlan(String plan) { + if (property == r'$set') { + client.setPersonProperties(userPropertiesToSet: {'plan': plan}); + } else { + client.setPersonProperties(userPropertiesToSetOnce: {'plan': plan}); + } + } + + setPlan('A'); + client.capture(r'$set', properties: { + property: {'plan': 'B'}, + }); + setPlan('A'); + await client.flush(); + + expect( + [ + for (final event in server.events) + ((event['properties']! as Map)[property] as Map)['plan'], + ], + ['A', 'B', 'A'], + ); + }); + } + + test('a direct set with the same properties suppresses the next duplicate', + () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + client.capture(r'$set', properties: { + r'$set': {'plan': 'A'}, + }); + client.setPersonProperties(userPropertiesToSet: {'plan': 'A'}); + expect(queuedEvents(storage), [r'$set']); + }); + + test('opt-out does not update the marker for the latest person update', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + client.setPersonProperties(userPropertiesToSet: {'plan': 'A'}); + client.optOut(); + client.capture(r'$set', properties: { + r'$set': {'plan': 'B'}, + }); + client.optIn(); + client.setPersonProperties(userPropertiesToSet: {'plan': 'A'}); + expect(queuedEvents(storage), [r'$set']); + }); + + test('null person properties do not break duplicate suppression', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + client.setPersonProperties(userPropertiesToSet: {'plan': 'A', 'old': null}); + client.setPersonProperties(userPropertiesToSet: {'plan': 'A', 'old': null}); + expect(queuedEvents(storage), [r'$set']); + }); + + test('event properties take precedence over registered properties', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + client.register({'plan': 'pro'}); + client.capture('purchase', properties: {'plan': 'free'}); + expect(queuedProps(storage, 0)['plan'], 'free'); + }); + + test( + 'session properties take precedence after caller properties and before registered properties', + () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + client.register({'plan': 'registered', 'screen': 'registered'}); + client.registerForSession({'plan': 'session', 'screen': 'Checkout'}); + client.capture('purchase', properties: {'plan': 'event'}); + expect(queuedProps(storage, 0)['plan'], 'event'); + expect(queuedProps(storage, 0)['screen'], 'Checkout'); + }); +} diff --git a/posthog_flutter/test/core/feature_flags_test.dart b/posthog_flutter/test/core/feature_flags_test.dart new file mode 100644 index 00000000..cd8c50ea --- /dev/null +++ b/posthog_flutter/test/core/feature_flags_test.dart @@ -0,0 +1,717 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/file_storage.dart'; +import 'package:posthog_flutter/src/core/persistence.dart'; +import 'package:posthog_flutter/src/feature_flag_result.dart'; +import 'package:posthog_flutter/src/posthog_desktop_client.dart'; +import 'package:posthog_flutter/src/posthog_flutter_version.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + late LocalPostHogServer server; + + setUp(() async { + server = await LocalPostHogServer.start(); + }); + + group('PostHogCore.reloadFeatureFlagsAsync', () { + test('sends non-string person properties to /flags/', () async { + final client = testClient(server); + + client.setPersonProperties( + userPropertiesToSet: {'age': 30, 'beta': true, 'plan': 'pro'}, + ); + await client.reloadFeatureFlagsAsync(); + + expect(_flagsRequestBody(server)['person_properties'], + {..._sdkProperties, 'age': 30, 'beta': true, 'plan': 'pro'}); + }); + + test('group properties keep their JSON types in the /flags request', + () async { + final client = testClient(server); + + client.setGroupPropertiesForFlags({ + 'company': {'beta': true, 'seats': 50, 'tier': 'scale'}, + }); + await client.reloadFeatureFlagsAsync(); + + expect(_flagsRequestBody(server)['group_properties'], { + 'company': {'beta': true, 'seats': 50, 'tier': 'scale'}, + }); + }); + + test('an empty response clears stale flags and the recorded error', + () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + + server.respond = + (_) => _flagsResponse({'stale-flag': _flag('stale-flag')}); + await client.reloadFeatureFlagsAsync(); + expect(client.getFeatureFlag('stale-flag'), isTrue); + + server.respond = + (_) => const PostHogResponse(HttpStatus.internalServerError); + await client.reloadFeatureFlagsAsync(); + expect(client.getFeatureFlag('stale-flag'), isTrue, + reason: 'a failed refresh keeps serving the cached flags'); + expect(_storedFlagDetails(storage)['requestError'], isNotNull); + + server.respond = (_) => _flagsResponse({}); + var notified = 0; + client.onFeatureFlags(() => notified++); + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('stale-flag'), isNull, + reason: 'an empty success is an answer, not a failure: ' + 'stale flags must not survive it'); + expect(notified, 1); + expect(_storedFlagDetails(storage).containsKey('requestError'), isFalse); + }); + + test('a malformed flag in the response is skipped, its neighbours stay', + () async { + final client = testClient(server); + server.respond = (_) => _flagsResponse({ + 'good-flag': _flag('good-flag'), + 'bad-flag': {'enabled': 'yes'}, + }); + + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('good-flag'), isTrue); + expect(client.getFeatureFlag('bad-flag'), isNull); + }); + + test('reloads queued behind one in flight get the next response', () async { + final client = testClient(server); + final release = Completer(); + server.respond = (request) async { + final personProperties = request.body['person_properties']! as Map; + if (!personProperties.containsKey('plan')) await release.future; + return _flagsResponse({ + if (personProperties['plan'] == 'pro') 'pro-flag': _flag('pro-flag'), + }); + }; + + final inFlight = client.reloadFeatureFlagsAsync(); + client.setPersonPropertiesForFlags({'plan': 'pro'}); + var displacedDone = false; + final displaced = + client.reloadFeatureFlagsAsync().then((_) => displacedDone = true); + client.setPersonPropertiesForFlags({'seats': 5}); + final latest = client.reloadFeatureFlagsAsync(); + release.complete(); + + await inFlight; + expect(displacedDone, isFalse, + reason: 'the displaced reload is answered by the request that runs ' + 'next, not by the one that was already in flight'); + await displaced; + await latest; + expect(client.getFeatureFlag('pro-flag'), isTrue); + expect(server.flagsRequests, hasLength(2)); + }); + + test('a throwing onFeatureFlags callback does not stop the flags', + () async { + final client = testClient(server); + server.respond = (_) => _flagsResponse({'beta-ui': _flag('beta-ui')}); + client.onFeatureFlags(() => throw StateError('callback failure')); + + await expectLater(client.reloadFeatureFlagsAsync(), completes); + + expect(client.getFeatureFlag('beta-ui'), isTrue); + }); + }); + + group('Feature flag reads', () { + test('a missing key reads as null through every accessor', () async { + final client = testClient(server); + server.respond = + (_) => _flagsResponse({'existing-flag': _flag('existing-flag')}); + await client.reloadFeatureFlagsAsync(); + + expect(client.isFeatureEnabled('existing-flag'), isTrue); + expect(client.getFeatureFlag('missing'), isNull); + expect(client.getFeatureFlagResult('missing', sendEvent: false), isNull); + expect(client.isFeatureEnabled('missing'), isNull); + }); + + test('payloads accompany only enabled flags', () async { + final client = testClient(server); + server.respond = (_) => _flagsResponse({ + 'paid-flag': _flag('paid-flag', payload: '{"tier":"gold"}'), + 'off-flag': _flag('off-flag', enabled: false, payload: '"nope"'), + }); + await client.reloadFeatureFlagsAsync(); + + expect( + client.getFeatureFlagResult('paid-flag', sendEvent: false)?.payload, + {'tier': 'gold'}); + final offResult = + client.getFeatureFlagResult('off-flag', sendEvent: false); + expect(offResult?.enabled, isFalse); + expect(offResult?.payload, isNull); + }); + + test('payloads decode to their JSON value, falsy ones included', () async { + final client = testClient(server); + const decodedBySerialized = { + '"hello"': 'hello', + '""': '', + 'false': false, + '0': 0, + '[1,false]': [1, false], + }; + final serialized = decodedBySerialized.keys.toList(); + server.respond = (_) => _flagsResponse({ + for (final (i, payload) in serialized.indexed) + 'flag-$i': _flag('flag-$i', payload: payload), + }); + await client.reloadFeatureFlagsAsync(); + + for (final (i, payload) in serialized.indexed) { + expect( + client.getFeatureFlagResult('flag-$i', sendEvent: false)?.payload, + decodedBySerialized[payload], + reason: payload); + } + }); + + for (final payload in ['{broken', '']) { + test('a payload of "$payload", not valid JSON, reads as no payload', + () async { + final client = testClient(server, config: testConfig(debug: true)); + server.respond = (_) => + _flagsResponse({'beta-ui': _flag('beta-ui', payload: payload)}); + await client.reloadFeatureFlagsAsync(); + + late final PostHogFeatureFlagResult? result; + final lines = printedLines(() => + result = client.getFeatureFlagResult('beta-ui', sendEvent: false)); + + expect(result?.enabled, isTrue); + expect(result?.payload, isNull, + reason: 'the raw string is not a payload'); + expect(lines, contains(contains('not valid JSON'))); + }); + } + + test('malformed persisted flag details are discarded, not thrown', () { + final dir = tempDirectory(); + // Valid JSON, unexpected shape - e.g. written by another SDK version + // sharing the same store. + FileStorage(dir.path) + ..setProperty(PostHogPersistedProperty.featureFlagDetails, + {'flags': 'garbage'}) + ..close(); + final storage = FileStorage(dir.path); + + final client = testClient(server, storage: storage); + + expect(() => client.capture('evt'), returnsNormally); + expect(getQueue(storage), hasLength(1)); + expect(client.getFeatureFlag('missing'), isNull); + expect( + storage.getProperty>( + PostHogPersistedProperty.featureFlagDetails), + isNull); + }); + }); + + group('Quota limiting', () { + test('keeps serving cached flags through every channel', () async { + final client = testClient(server); + server.respond = + (_) => _flagsResponse({'cached-flag': _flag('cached-flag')}); + await client.reloadFeatureFlagsAsync(); + + server.respond = + (_) => _flagsResponse({}, quotaLimited: ['feature_flags']); + final notified = []; + client.onFeatureFlags( + () => notified.add(client.getFeatureFlag('cached-flag'))); + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('cached-flag'), isTrue, + reason: 'cached flags keep serving under quota, so every channel ' + 'must report the cached state'); + expect(notified, [isTrue]); + }); + }); + + group('Person properties for flags', () { + const context = { + r'$app_version': '9.9.9', + r'$os_name': 'TestOS', + }; + + test('default person properties are sent with the /flags/ request', + () async { + final client = testClient(server, context: context); + + await client.reloadFeatureFlagsAsync(); + + expect(_flagsRequestBody(server)['person_properties'], + {...context, ..._sdkProperties}); + }); + + test('explicitly set person properties override the defaults', () async { + final client = testClient(server, context: context); + + client.setPersonPropertiesForFlags({r'$os_name': 'ManualOS'}); + await client.reloadFeatureFlagsAsync(); + + expect(_flagsRequestBody(server)['person_properties'], + {...context, ..._sdkProperties, r'$os_name': 'ManualOS'}); + }); + + test('person properties with a DateTime reach /flags/ as a string', + () async { + final client = testClient(server); + + client.setPersonPropertiesForFlags({'signed_up': DateTime.utc(2024)}); + client.setGroupPropertiesForFlags({ + 'company': {'founded': DateTime.utc(2020)}, + }); + await client.reloadFeatureFlagsAsync(); + + final body = _flagsRequestBody(server); + expect(body['person_properties'], + {..._sdkProperties, 'signed_up': '2024-01-01T00:00:00.000Z'}); + expect(body['group_properties'], { + 'company': {'founded': '2020-01-01T00:00:00.000Z'}, + }); + }); + + test('identify user properties feed the next /flags/ request', () async { + final client = testClient(server); + + client.identify( + 'user-1', + userProperties: {'plan': 'pro'}, + userPropertiesSetOnce: {'plan': 'trial', 'signup_source': 'ads'}, + ); + + final body = await server.waitForFlagsRequest(0); + expect(body['person_properties'], + {..._sdkProperties, 'plan': 'pro', 'signup_source': 'ads'}, + reason: r'$set wins over $set_once for the same key'); + }); + }); + + group('Feature flag request body', () { + test('carries the device id through identify and reset', () async { + final dir = tempDirectory(); + final client = testClient(server, storage: FileStorage(dir.path)); + final deviceId = client.getDistinctId(); + + await client.reloadFeatureFlagsAsync(); + expect(_flagsRequestBody(server)[r'$device_id'], deviceId); + + client.identify('user-123'); + client.reset(); + await client.reloadFeatureFlagsAsync(); + + final body = _flagsRequestBody(server); + expect(body['distinct_id'], isNot(anyOf('user-123', deviceId))); + expect(body[r'$device_id'], deviceId, + reason: 'device-level bucketing must not change with the user'); + + client.close(); + final restarted = testClient(server, storage: FileStorage(dir.path)); + await restarted.reloadFeatureFlagsAsync(); + expect(_flagsRequestBody(server)[r'$device_id'], deviceId); + }); + + test('carries the time zone a platform provides', () async { + final client = testClient(server, timezone: 'Europe/Berlin'); + + await client.reloadFeatureFlagsAsync(); + + expect(_flagsRequestBody(server)['timezone'], 'Europe/Berlin'); + }); + + test('has no time zone by default', () async { + final client = testClient(server); + + await client.reloadFeatureFlagsAsync(); + + expect(_flagsRequestBody(server).containsKey('timezone'), isFalse); + }); + }); + + group('Feature flag requests', () { + for (final status in [502, 504]) { + test('retry once after HTTP $status', () async { + final client = testClient(server); + var attempts = 0; + server.respond = (_) => ++attempts == 1 + ? PostHogResponse(status) + : _flagsResponse({'beta-ui': _flag('beta-ui')}); + + await client.reloadFeatureFlagsAsync(); + expect(client.getFeatureFlag('beta-ui'), isTrue); + expect(server.flagsRequests, hasLength(2)); + }); + } + + test('retry once after a network error', () async { + final client = testClient(server); + var attempts = 0; + server.respond = (_) => ++attempts == 1 + ? const PostHogResponse.dropped() + : _flagsResponse({'beta-ui': _flag('beta-ui')}); + + await client.reloadFeatureFlagsAsync(); + expect(client.getFeatureFlag('beta-ui'), isTrue); + expect(server.flagsRequests, hasLength(2)); + }); + + test('stop after one retry', () async { + final client = testClient(server); + server.respond = (_) => const PostHogResponse(HttpStatus.badGateway); + + await client.reloadFeatureFlagsAsync(); + expect(client.getFeatureFlag('beta-ui'), isNull); + expect(server.flagsRequests, hasLength(2)); + }); + + test('time out after ten seconds, and once more after the retry', () { + final api = InProcessPostHogApi() + ..respond = (_) => Completer().future; + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, storage: storage); + + client.reloadFeatureFlagsAsync(); + async.elapse(const Duration(seconds: 10)); + expect(api.flagsRequests, hasLength(1)); + async.elapse(const Duration(milliseconds: 300)); + expect(api.flagsRequests, hasLength(2)); + expect(_storedFlagDetails(storage), isEmpty); + async.elapse(const Duration(seconds: 10)); + + expect( + _storedFlagDetails(storage)['requestError'], {'type': 'timeout'}); + }); + }); + + for (final status in [408, 429, 500, 503]) { + test('are not retried for HTTP $status', () async { + final client = testClient(server); + server.respond = (_) => PostHogResponse(status); + + await client.reloadFeatureFlagsAsync(); + expect(client.getFeatureFlag('beta-ui'), isNull); + expect(server.flagsRequests, hasLength(1)); + }); + } + }); + + group(r'$active_feature_flags', () { + test('lists only enabled flags', () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + server.respond = (_) => _flagsResponse({ + 'on-flag': _flag('on-flag'), + 'variant-flag': _flag('variant-flag', variant: 'test'), + 'off-flag': _flag('off-flag', enabled: false), + }); + await client.reloadFeatureFlagsAsync(); + + client.capture('evt'); + + final props = queuedProps(storage, 0); + expect(props[r'$active_feature_flags'], ['on-flag', 'variant-flag']); + expect(props[r'$feature/off-flag'], isFalse); + }); + + test('is left out when no flag is enabled', () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + server.respond = (_) => + _flagsResponse({'off-flag': _flag('off-flag', enabled: false)}); + await client.reloadFeatureFlagsAsync(); + + client.capture('evt'); + + expect(queuedProps(storage, 0).containsKey(r'$active_feature_flags'), + isFalse); + }); + }); + + group(r'$feature_flag_called tracking', () { + late FileStorage storage; + late DesktopPostHog client; + late Map flags; + + setUp(() { + storage = tempStorage(); + client = testClient(server, storage: storage); + flags = {'beta-ui': _flag('beta-ui')}; + server.respond = (_) => _flagsResponse(flags); + }); + + test('reports a flag value once, even across reloads', () async { + await client.reloadFeatureFlagsAsync(); + + client.getFeatureFlag('beta-ui'); + client.isFeatureEnabled('beta-ui'); + await client.reloadFeatureFlagsAsync(); + client.getFeatureFlag('beta-ui'); + + expect(_flagCalledCount(storage), 1); + }); + + test('reports a flag again when its value changes', () async { + await client.reloadFeatureFlagsAsync(); + client.getFeatureFlag('beta-ui'); + + flags = {'beta-ui': _flag('beta-ui', variant: 'test')}; + await client.reloadFeatureFlagsAsync(); + client.getFeatureFlag('beta-ui'); + + expect(_flagCalledCount(storage), 2); + }); + + test('reports a flag again after reset', () async { + await client.reloadFeatureFlagsAsync(); + client.getFeatureFlag('beta-ui'); + + client.reset(); + await client.reloadFeatureFlagsAsync(); + client.getFeatureFlag('beta-ui'); + + expect(_flagCalledCount(storage), 2); + }); + + test('carries whether the flag is linked to an experiment', () async { + flags = {'beta-ui': _flag('beta-ui', hasExperiment: true)}; + await client.reloadFeatureFlagsAsync(); + + client.getFeatureFlag('beta-ui'); + + expect( + _flagCalledProps(storage)[r'$feature_flag_has_experiment'], isTrue); + }); + }); + + group(r'Minimal $feature_flag_called events', () { + late FileStorage storage; + late DesktopPostHog client; + + setUp(() { + storage = tempStorage(); + client = testClient(server, storage: storage, context: { + r'$os_name': 'TestOS', + r'$app_version': '1.0.0', + r'$device_type': 'Desktop', + }); + client.register({ + 'team': 'growth', + 'utm_source': 'newsletter', + r'$referring_domain': 'example.com', + r'$referrer': 'https://example.com/private?token=1', + }); + }); + + Future loadFlag({bool? minimal, bool? hasExperiment}) async { + server.respond = (_) => _flagsResponse( + {'beta-ui': _flag('beta-ui', hasExperiment: hasExperiment)}, + minimalFlagCalledEvents: minimal, + ); + await client.reloadFeatureFlagsAsync(); + } + + test('keep only the allowlisted properties for a flag without experiment', + () async { + await loadFlag(minimal: true, hasExperiment: false); + + client.getFeatureFlag('beta-ui'); + + expect(_flagCalledProps(storage).keys.toSet(), { + r'$feature_flag', + r'$feature_flag_response', + r'$feature_flag_has_experiment', + r'$process_person_profile', + r'$session_id', + r'$lib', + r'$lib_version', + r'$os_name', + r'$app_version', + r'$referring_domain', + 'utm_source', + }); + }); + + // (description, server gate, flag has_experiment) + const fullEventCases = <(String, bool?, bool?)>[ + ('the server does not ask for them', null, false), + ('the flag is linked to an experiment', true, true), + ('the experiment link is unknown', true, null), + ]; + + for (final (description, minimal, hasExperiment) in fullEventCases) { + test('are not sent when $description', () async { + await loadFlag(minimal: minimal, hasExperiment: hasExperiment); + + client.getFeatureFlag('beta-ui'); + + final props = _flagCalledProps(storage); + expect(props['team'], 'growth'); + expect(props[r'$feature/beta-ui'], isTrue); + }); + } + + test('stay on through a failed flags reload', () async { + await loadFlag(minimal: true, hasExperiment: false); + server.respond = (_) => const PostHogResponse(HttpStatus.badRequest); + await client.reloadFeatureFlagsAsync(); + + client.getFeatureFlag('beta-ui'); + + expect(_flagCalledProps(storage).containsKey('team'), isFalse); + }); + }); + + group('Feature flag preloading', () { + test('a new client requests /flags/ once when preloading is on', () async { + final client = + testClient(server, config: testConfig(preloadFeatureFlags: true)); + final loaded = Completer(); + client.onFeatureFlags(loaded.complete); + + await loaded.future; + + expect(server.flagsRequests, hasLength(1)); + }); + + test('no /flags/ request is made when preloading is off', () async { + final client = testClient(server); + + await client.reloadFeatureFlagsAsync(); + + expect(server.flagsRequests, hasLength(1), + reason: 'a preload would still be in flight, so the reload above ' + 'would make a second request'); + }); + }); + + group('Flags cached by an earlier launch', () { + /// Runs a launch that caches in [dir] the flags [api] answers. + void launchEarlier(FakeAsync async, PostHogApiFake api, Directory dir) { + final client = testClient(api, storage: FileStorage(dir.path)); + client.reloadFeatureFlagsAsync(); + async.flushMicrotasks(); + client.close(); + } + + test('are announced before the preload response, which follows', () { + final api = InProcessPostHogApi() + ..respond = (_) => _flagsResponse({'beta-ui': _flag('beta-ui')}); + final dir = tempDirectory(); + fakeAsync((async) { + launchEarlier(async, api, dir); + final response = Completer(); + api.respond = (_) => response.future; + final client = testClient(api, + config: testConfig(preloadFeatureFlags: true), + storage: FileStorage(dir.path)); + final announced = []; + client.onFeatureFlags( + () => announced.add(client.getFeatureFlag('beta-ui'))); + + async.elapse(Duration.zero); + expect(announced, [isTrue]); + + response.complete( + _flagsResponse({'beta-ui': _flag('beta-ui', enabled: false)})); + async.flushMicrotasks(); + expect(announced, [isTrue, isFalse]); + }); + }); + + test('are not announced after a preload response that came first', () { + final api = InProcessPostHogApi() + ..respond = (_) => _flagsResponse({'beta-ui': _flag('beta-ui')}); + final dir = tempDirectory(); + fakeAsync((async) { + launchEarlier(async, api, dir); + api.respond = (_) => + _flagsResponse({'beta-ui': _flag('beta-ui', enabled: false)}); + final client = testClient(api, + config: testConfig(preloadFeatureFlags: true), + storage: FileStorage(dir.path)); + final announced = []; + client.onFeatureFlags( + () => announced.add(client.getFeatureFlag('beta-ui'))); + + async.flushMicrotasks(); + async.elapse(Duration.zero); + + expect(announced, [isFalse], + reason: 'the cached flags are stale once the response is in'); + }); + }); + }); +} + +/// The person properties every `/flags/` request of the desktop client +/// carries. +const _sdkProperties = { + r'$lib': postHogFlutterSdkName, + r'$lib_version': postHogFlutterVersion, +}; + +Map _flag( + String key, { + bool enabled = true, + String? variant, + String? payload, + bool? hasExperiment, +}) => + { + 'key': key, + 'enabled': enabled, + if (variant != null) 'variant': variant, + if (payload != null || hasExperiment != null) + 'metadata': { + if (payload != null) 'payload': payload, + if (hasExperiment != null) 'has_experiment': hasExperiment, + }, + }; + +PostHogResponse _flagsResponse( + Map flags, { + List? quotaLimited, + bool? minimalFlagCalledEvents, +}) => + PostHogResponse.json({ + 'flags': flags, + if (quotaLimited != null) 'quotaLimited': quotaLimited, + if (minimalFlagCalledEvents != null) + 'minimalFlagCalledEvents': minimalFlagCalledEvents, + }); + +Map _flagCalledProps(FileStorage storage) => + queuedPropsOf(storage, r'$feature_flag_called'); + +int _flagCalledCount(FileStorage storage) => queuedEvents(storage) + .where((event) => event == r'$feature_flag_called') + .length; + +Map _flagsRequestBody(PostHogApiFake api) => + api.flagsRequests.last.body; + +Map _storedFlagDetails(FileStorage storage) => + storage.getProperty>( + PostHogPersistedProperty.featureFlagDetails) ?? + {}; diff --git a/posthog_flutter/test/core/file_queue_test.dart b/posthog_flutter/test/core/file_queue_test.dart new file mode 100644 index 00000000..cb849826 --- /dev/null +++ b/posthog_flutter/test/core/file_queue_test.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/file_storage.dart'; +import 'package:posthog_flutter/src/posthog_config.dart'; +import 'package:posthog_flutter/src/posthog_desktop_client.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + late Directory dir; + late LocalPostHogServer server; + + setUp(() async { + dir = tempDirectory(); + server = await LocalPostHogServer.start(); + }); + + DesktopPostHog client({PostHogConfig? config}) => + testClient(server, config: config, storage: FileStorage(dir.path)); + + test('queueing an event writes one file and rewrites nothing else', () async { + final posthog = client(); + posthog.identify('user-1'); + posthog.capture('first'); + // Waits for the flags reload identify() started, which stores its answer. + await posthog.reloadFeatureFlagsAsync(); + final snapshot = File('${dir.path}/posthog_data.json'); + final earlier = DateTime(2000); + for (final file in [snapshot, ..._queueFiles(dir)]) { + file.setLastModifiedSync(earlier); + } + + posthog.capture('second'); + + expect(snapshot.lastModifiedSync(), earlier); + final files = _queueFiles(dir); + expect([for (final file in files) _eventOf(file)], + [r'$identify', 'first', 'second']); + expect(files.where((file) => file.lastModifiedSync() != earlier), + hasLength(1)); + }); + + test('keeps the queue order across a restart', () async { + final before = client(); + for (var i = 0; i < 50; i++) { + before.capture('event_$i'); + } + before.close(); + + await client().flush(); + + expect(server.eventNames, [for (var i = 0; i < 50; i++) 'event_$i']); + expect(_queueFiles(dir), isEmpty); + }); + + test('drops the oldest event files once the queue is full', () { + final api = InProcessPostHogApi() + ..respond = (_) => const PostHogResponse.dropped(); + fakeAsync((async) { + final posthog = testClient(api, + config: testConfig(flushAt: 2, maxQueueSize: 2), + storage: FileStorage(dir.path)); + + for (final event in ['first', 'second', 'third']) { + posthog.capture(event); + } + async.elapse(Duration.zero); + + expect([for (final file in _queueFiles(dir)) _eventOf(file)], + ['second', 'third']); + }); + }); + + test('limits the restored queue before the first timer without capture', () { + final before = client(); + for (var i = 0; i < 5; i++) { + before.capture('event_$i'); + } + final original = [ + for (final file in _queueFiles(dir).skip(3)) + jsonDecode(file.readAsStringSync()), + ]; + before.close(); + + final api = InProcessPostHogApi(); + fakeAsync((async) { + testClient(api, + config: testConfig(maxQueueSize: 2, flushAt: 1), + storage: FileStorage(dir.path)); + expect([for (final file in _queueFiles(dir)) _eventOf(file)], + ['event_3', 'event_4']); + expect(api.requests, isEmpty); + async.elapse(const Duration(seconds: 30)); + expect(api.batchRequests.single.events, original); + expect(_queueFiles(dir), isEmpty); + }); + }); + + for (final (capacity, remaining) in [(5, 3), (3, 3), (0, 1)]) { + test('restores $remaining events with maxQueueSize $capacity', () async { + final before = client(); + for (var i = 0; i < 3; i++) { + before.capture('event_$i'); + } + before.close(); + await client(config: testConfig(maxQueueSize: capacity)).flush(); + expect(server.eventNames, + [for (var i = 3 - remaining; i < 3; i++) 'event_$i']); + }); + } + + test('restoring a secondary queue does not remove primary queue events', + () async { + final primary = client(); + primary.capture('first'); + primary.capture('second'); + final secondary = client(config: testConfig(maxQueueSize: 1)); + secondary.capture('secondary'); + await secondary.flush(); + expect([for (final file in _queueFiles(dir)) _eventOf(file)], + ['first', 'second']); + await primary.flush(); + expect(server.eventNames, ['secondary', 'first', 'second']); + }); + + test('deletes a queued event that cannot be read and sends the others', + () async { + final before = client(); + for (final event in ['first', 'second', 'third']) { + before.capture(event); + } + before.close(); + final corrupt = _queueFiles(dir)[1]..writeAsStringSync('{"event": "sec'); + + final after = client(config: testConfig(debug: true)); + late Future flushed; + final lines = printedLines(() => flushed = after.flush()); + await flushed; + + expect(server.eventNames, ['first', 'third']); + expect(lines, contains(contains('cannot be read'))); + expect(corrupt.existsSync(), isFalse); + }); + + test("two clients on one directory never delete each other's events", + () async { + final first = client(); + final second = client(); + + first.capture('from first'); + second.capture('from second'); + await first.flush(); + + expect(server.eventNames, ['from first']); + expect( + [for (final file in _queueFiles(dir)) _eventOf(file)], ['from second']); + }); + + test('an event whose file cannot be written is still sent', () async { + addTearDown(() => chmod('755', dir.path)); + final posthog = client(); + chmod('555', dir.path); + + posthog.capture('evt'); + await posthog.flush(); + + expect(server.eventNames, ['evt']); + expect(Directory('${dir.path}/posthog_queue').existsSync(), isFalse); + }, skip: chmodSkip); +} + +/// The event files of the queue in [dir], oldest first. +List _queueFiles(Directory dir) { + final queue = Directory('${dir.path}/posthog_queue'); + if (!queue.existsSync()) return []; + return queue.listSync().whereType().toList() + ..sort((a, b) => a.path.compareTo(b.path)); +} + +Object? _eventOf(File file) => + (jsonDecode(file.readAsStringSync()) as Map)['event']; diff --git a/posthog_flutter/test/core/file_storage_lock_test.dart b/posthog_flutter/test/core/file_storage_lock_test.dart new file mode 100644 index 00000000..614217ab --- /dev/null +++ b/posthog_flutter/test/core/file_storage_lock_test.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/file_storage.dart'; +import 'package:posthog_flutter/src/core/persistence.dart'; +import 'package:posthog_flutter/src/posthog_desktop_client.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + late Directory dir; + + setUp(() { + dir = tempDirectory(); + }); + + FileStorage storage() { + final storage = FileStorage(dir.path); + addTearDown(storage.close); + return storage; + } + + String? storedDistinctId() => FileStorage(dir.path) + .getProperty(PostHogPersistedProperty.distinctId); + + test('a second storage on the directory keeps its changes in memory', () { + final first = storage(); + final second = storage(); + + first.setProperty(PostHogPersistedProperty.distinctId, 'first-user'); + second.setProperty(PostHogPersistedProperty.distinctId, 'second-user'); + + expect(second.getProperty(PostHogPersistedProperty.distinctId), + 'second-user'); + expect(storedDistinctId(), 'first-user'); + }); + + test('a client on a directory in use logs it once and queues as usual', + () async { + final server = await LocalPostHogServer.start(); + testClient(server, storage: storage()); + + late DesktopPostHog second; + final lines = printedLines(() { + second = testClient(server, + config: testConfig(debug: true), storage: storage()); + second + ..identify('second-user') + ..capture('from second'); + }); + + expect(lines.where((line) => line.contains('Another PostHog client')), + hasLength(1)); + expect(second.getDistinctId(), 'second-user'); + expect(storedDistinctId(), isNull, + reason: 'the identity on disk belongs to the first client'); + expect(_queuedOnDisk(dir), [r'$identify', 'from second']); + }); + + test('the next storage takes over once the holder is closed', () { + final first = storage() + ..setProperty(PostHogPersistedProperty.distinctId, 'first-user'); + final second = storage() + ..setProperty(PostHogPersistedProperty.distinctId, 'second-user'); + + first.close(); + storage().setProperty(PostHogPersistedProperty.distinctId, 'third-user'); + second.setProperty(PostHogPersistedProperty.distinctId, 'second-again'); + + expect(storedDistinctId(), 'third-user'); + }); + + test('a closed client releases the directory', () async { + final server = await LocalPostHogServer.start(); + testClient(server, storage: storage()).close(); + + final next = testClient(server, storage: storage()); + next.identify('next-user'); + + expect(storedDistinctId(), 'next-user'); + }); + + test('a directory locked by another process is left to it', () async { + final releaseLock = await _lockInAnotherProcess(dir); + addTearDown(releaseLock); + + final blocked = storage() + ..setProperty(PostHogPersistedProperty.distinctId, 'blocked-user'); + blocked.queue.add({'event': 'queued meanwhile'}); + + expect(storedDistinctId(), isNull); + expect(_queuedOnDisk(dir), ['queued meanwhile']); + + await releaseLock(); + storage().setProperty(PostHogPersistedProperty.distinctId, 'next-user'); + + expect(storedDistinctId(), 'next-user'); + }, + skip: Platform.isMacOS + ? 'FileStorage runs on Linux and Windows, and tells a lock held ' + 'by another process by their error codes' + : false); +} + +/// The names of the events queued on disk in [dir], oldest first. +List _queuedOnDisk(Directory dir) { + final files = Directory('${dir.path}/posthog_queue') + .listSync() + .whereType() + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + return [ + for (final file in files) + (jsonDecode(file.readAsStringSync()) as Map)['event'], + ]; +} + +/// Locks [dir] the way [FileStorage] does, from a separate process. Returns +/// a function that ends that process, releasing the lock. +Future Function()> _lockInAnotherProcess(Directory dir) async { + final script = File('${dir.path}/lock_holder.dart')..writeAsStringSync(r''' +import 'dart:io'; + +void main(List args) { + File(args.single) + .openSync(mode: FileMode.append) + .lockSync(FileLock.exclusive); + print('locked'); + stdin.listen(null, onDone: () => exit(0)); +} +'''); + final process = await Process.start(_dartExecutable(), + [script.path, '${dir.path}${Platform.pathSeparator}posthog.lock']); + await process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .firstWhere((line) => line == 'locked'); + + var released = false; + return () async { + if (released) return; + released = true; + await process.stdin.close(); + await process.exitCode; + }; +} + +/// The Dart VM of the Flutter SDK running the tests: `flutter test` runs them +/// in flutter_tester, which cannot run a script of its own. +String _dartExecutable() { + final flutterRoot = Platform.environment['FLUTTER_ROOT']; + if (flutterRoot == null) fail('FLUTTER_ROOT is not set; run `flutter test`'); + final sep = Platform.pathSeparator; + final dart = Platform.isWindows ? 'dart.exe' : 'dart'; + return '$flutterRoot${sep}bin${sep}cache${sep}dart-sdk${sep}bin$sep$dart'; +} diff --git a/posthog_flutter/test/core/file_storage_test.dart b/posthog_flutter/test/core/file_storage_test.dart new file mode 100644 index 00000000..a1fc74ba --- /dev/null +++ b/posthog_flutter/test/core/file_storage_test.dart @@ -0,0 +1,292 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/file_storage.dart'; +import 'package:posthog_flutter/src/core/logger.dart'; +import 'package:posthog_flutter/src/core/persistence.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + group('FileStorage', () { + test('setting a property to null removes it from the persisted snapshot', + () { + final dir = Directory.systemTemp.createTempSync('posthog_storage_null'); + addTearDown(() => dir.deleteSync(recursive: true)); + final storage = FileStorage(dir.path); + addTearDown(storage.close); + storage.setProperty(PostHogPersistedProperty.distinctId, 'user-1'); + storage.setProperty(PostHogPersistedProperty.anonymousId, 'anon-1'); + + storage.setProperty(PostHogPersistedProperty.distinctId, null); + + final reopened = FileStorage(dir.path); + addTearDown(reopened.close); + expect(reopened.getProperty(PostHogPersistedProperty.distinctId), + isNull); + expect(reopened.getProperty(PostHogPersistedProperty.anonymousId), + 'anon-1'); + }); + + test('reads a stored value of an unexpected type as null', () { + final dir = Directory.systemTemp.createTempSync('posthog_storage_type'); + addTearDown(() => dir.deleteSync(recursive: true)); + + final storage = FileStorage(dir.path); + addTearDown(storage.close); + storage.setProperty(PostHogPersistedProperty.props, 'garbage'); + + final reopened = FileStorage(dir.path); + addTearDown(reopened.close); + expect( + reopened.getProperty>( + PostHogPersistedProperty.props), + isNull); + expect(reopened.getProperty(PostHogPersistedProperty.props), + 'garbage'); + }); + + test('drops a value that cannot be JSON-encoded', () { + final dir = Directory.systemTemp.createTempSync('posthog_storage'); + addTearDown(() => dir.deleteSync(recursive: true)); + final storage = FileStorage(dir.path); + addTearDown(storage.close); + + storage.setProperty( + PostHogPersistedProperty.props, {'date': DateTime.now()}); + + expect( + storage.getProperty>( + PostHogPersistedProperty.props), + isNull, + reason: 'a value the snapshot cannot serialize must not stay in ' + 'the cache, or every later write would fail'); + + // The store keeps working after the rejected write. + storage.setProperty(PostHogPersistedProperty.distinctId, 'id-1'); + final reopened = FileStorage(dir.path); + addTearDown(reopened.close); + expect(reopened.getProperty(PostHogPersistedProperty.distinctId), + 'id-1'); + }); + + // (description, on-disk bytes) + final utf8Corruptions = <(String, List)>[ + ( + 'a multi-byte character truncated by a torn write', + [0x7b, 0x22, 0xd0], + ), + ( + // {"distinct_id":"a<0xD0>b"}: the JSON structure is intact, but the + // continuation-less byte fails strict UTF-8 decoding. + 'an invalid byte inside a string value', + [...'{"distinct_id":"a'.codeUnits, 0xd0, ...'b"}'.codeUnits], + ), + ]; + + for (final (description, bytes) in utf8Corruptions) { + test('resets the store when the file holds $description', () { + final dir = Directory.systemTemp.createTempSync('posthog_storage_utf8'); + addTearDown(() => dir.deleteSync(recursive: true)); + File('${dir.path}/posthog_data.json').writeAsBytesSync(bytes); + + final storage = FileStorage(dir.path); + addTearDown(storage.close); + expect(storage.isDegraded, isFalse); + expect(storage.getProperty(PostHogPersistedProperty.distinctId), + isNull, + reason: 'corrupt bytes must reset the store, not brick it or ' + 'resurrect mangled values via lenient decoding'); + + // Self-heal: the next write persists a fresh valid snapshot. + storage.setProperty(PostHogPersistedProperty.distinctId, 'healed'); + final reopened = FileStorage(dir.path); + addTearDown(reopened.close); + expect( + reopened.getProperty(PostHogPersistedProperty.distinctId), + 'healed'); + }); + } + + // (description, on-disk content) + const corruptContents = <(String, String)>[ + ('an empty file', ''), + ('a JSON string', '"posthog"'), + ('a JSON array', '[1, 2]'), + ('truncated JSON', '{"distinct_id":'), + ]; + + for (final (description, content) in corruptContents) { + test('resets a store holding $description and heals on the next write', + () { + final dir = Directory.systemTemp.createTempSync('posthog_storage_bad'); + addTearDown(() => dir.deleteSync(recursive: true)); + File('${dir.path}/posthog_data.json').writeAsStringSync(content); + + final storage = FileStorage(dir.path); + addTearDown(storage.close); + expect(storage.isDegraded, isFalse); + expect(storage.getProperty(PostHogPersistedProperty.distinctId), + isNull); + + storage.setProperty(PostHogPersistedProperty.distinctId, 'healed'); + final reopened = FileStorage(dir.path); + addTearDown(reopened.close); + expect( + reopened.getProperty(PostHogPersistedProperty.distinctId), + 'healed'); + }); + } + + test('a failed write keeps the value in memory and does not throw', () { + final dir = Directory.systemTemp.createTempSync('posthog_storage_ro'); + addTearDown(() { + chmod('755', dir.path); + dir.deleteSync(recursive: true); + }); + final storage = FileStorage(dir.path); + addTearDown(storage.close); + storage.setProperty(PostHogPersistedProperty.distinctId, 'a'); + + // Snapshots go through tmp+rename, so blocking the write means + // removing write permission from the directory, not the file. + chmod('555', dir.path); + + expect( + () => storage.setProperty(PostHogPersistedProperty.distinctId, 'b'), + returnsNormally); + expect(storage.getProperty(PostHogPersistedProperty.distinctId), + 'b'); + + chmod('755', dir.path); + storage.setProperty(PostHogPersistedProperty.anonymousId, 'anon'); + // The next successful write persists the whole snapshot, 'b' included. + final reopened = FileStorage(dir.path); + addTearDown(reopened.close); + expect(reopened.getProperty(PostHogPersistedProperty.distinctId), + 'b'); + }, skip: chmodSkip); + + test('a failed write leaves the on-disk snapshot untouched', () { + final dir = Directory.systemTemp.createTempSync('posthog_storage_disk'); + addTearDown(() { + chmod('755', dir.path); + dir.deleteSync(recursive: true); + }); + final storage = FileStorage(dir.path); + addTearDown(storage.close); + storage.setProperty(PostHogPersistedProperty.distinctId, 'a'); + + chmod('555', dir.path); + storage.setProperty(PostHogPersistedProperty.distinctId, 'b'); + + final reopened = FileStorage(dir.path); + addTearDown(reopened.close); + expect(reopened.getProperty(PostHogPersistedProperty.distinctId), + 'a', + reason: 'the data file itself stayed writable, so an in-place ' + 'write (instead of tmp+rename) would have replaced the good ' + 'snapshot'); + }, skip: chmodSkip); + + test('reports a failed write to its logger', () { + final dir = Directory.systemTemp.createTempSync('posthog_storage_log'); + addTearDown(() { + chmod('755', dir.path); + dir.deleteSync(recursive: true); + }); + final storage = FileStorage(dir.path) + ..logger = CoreLogger((log) => log()); + addTearDown(storage.close); + storage.setProperty(PostHogPersistedProperty.distinctId, 'a'); + chmod('555', dir.path); + + final lines = printedLines( + () => storage.setProperty(PostHogPersistedProperty.distinctId, 'b')); + + expect(lines, [contains('Failed to persist')]); + }, skip: chmodSkip); + + for (final debug in [true, false]) { + test('a client with debug: $debug reports write failures accordingly', + () async { + final server = await LocalPostHogServer.start(); + final dir = Directory.systemTemp.createTempSync('posthog_client_log'); + addTearDown(() { + chmod('755', dir.path); + dir.deleteSync(recursive: true); + }); + final client = testClient(server, + config: testConfig(debug: debug), storage: FileStorage(dir.path)); + chmod('555', dir.path); + + final lines = printedLines(() => client.capture('evt')); + + expect(lines.where((line) => line.contains('Failed to persist')), + debug ? isNotEmpty : isEmpty); + }, skip: chmodSkip); + } + + test('writes while the disk is unreadable stay in memory, disk intact', () { + final dir = Directory.systemTemp.createTempSync('posthog_storage_rd'); + final dataFile = '${dir.path}/posthog_data.json'; + addTearDown(() { + chmod('644', dataFile); + dir.deleteSync(recursive: true); + }); + final storage = FileStorage(dir.path); + addTearDown(storage.close); + storage + ..setProperty(PostHogPersistedProperty.distinctId, 'keep') + ..setProperty(PostHogPersistedProperty.anonymousId, 'anon') + ..close(); + + chmod('000', dataFile); + + final blind = FileStorage(dir.path)..logger = CoreLogger((log) => log()); + addTearDown(blind.close); + expect(blind.isDegraded, isTrue); + final lines = printedLines(() => + blind.setProperty(PostHogPersistedProperty.distinctId, 'in-memory')); + expect(blind.getProperty(PostHogPersistedProperty.distinctId), + 'in-memory', + reason: 'the running client must stay consistent, e.g. keep one ' + 'anonymous id instead of generating one per read'); + expect(lines, [contains('in memory only')]); + + chmod('644', dataFile); + expect(blind.isDegraded, isFalse, + reason: 'degradation describes the disk, not the instance: it ' + 'must lift as soon as the disk is readable again'); + expect(blind.getProperty(PostHogPersistedProperty.distinctId), + 'keep'); + expect(blind.getProperty(PostHogPersistedProperty.anonymousId), + 'anon'); + }, skip: chmodSkip); + + test('an unreadable directory reads as degraded, not as a fresh store', () { + final dir = Directory.systemTemp.createTempSync('posthog_storage_dir'); + final sub = Directory('${dir.path}/store')..createSync(); + addTearDown(() { + chmod('755', sub.path); + dir.deleteSync(recursive: true); + }); + final storage = FileStorage(sub.path); + addTearDown(storage.close); + storage.setProperty(PostHogPersistedProperty.distinctId, 'keep'); + + chmod('000', sub.path); + + final blind = FileStorage(sub.path); + addTearDown(blind.close); + expect(blind.isDegraded, isTrue); + expect(blind.getProperty(PostHogPersistedProperty.distinctId), + isNull); + + chmod('755', sub.path); + expect(blind.getProperty(PostHogPersistedProperty.distinctId), + 'keep'); + }, skip: chmodSkip); + }); +} diff --git a/posthog_flutter/test/core/legacy_flags_test.dart b/posthog_flutter/test/core/legacy_flags_test.dart new file mode 100644 index 00000000..f8d527f0 --- /dev/null +++ b/posthog_flutter/test/core/legacy_flags_test.dart @@ -0,0 +1,185 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + late LocalPostHogServer server; + + setUp(() async { + server = await LocalPostHogServer.start(); + }); + + test( + 'legacy: bool, variant, and JSON payload are available through the client', + () async { + final client = testClient(server); + server.respond = (_) => PostHogResponse.json({ + 'featureFlags': {'on': true, 'off': false, 'variant': 'blue'}, + 'featureFlagPayloads': { + 'on': '{"color":"green"}', + 'off': '{"hidden":true}', + 'variant': '"hello"', + }, + }); + + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('on'), isTrue); + expect(client.getFeatureFlag('off'), isFalse); + expect(client.getFeatureFlag('variant'), 'blue'); + expect(client.getFeatureFlagResult('on', sendEvent: false)?.payload, + {'color': 'green'}); + expect( + client.getFeatureFlagResult('off', sendEvent: false)?.payload, isNull); + final result = client.getFeatureFlagResult('variant', sendEvent: false)!; + expect(result.enabled, isTrue); + expect(result.variant, 'blue'); + expect(result.payload, 'hello'); + expect(server.flagsRequests, hasLength(1)); + }); + + for (final rich in >[ + {}, + { + 'flag': {'key': 'flag', 'enabled': false} + }, + ]) { + test('rich flags $rich take precedence over legacy flags', () async { + final client = testClient(server); + server.respond = (_) => PostHogResponse.json({ + 'featureFlags': {'flag': true}, + }); + await client.reloadFeatureFlagsAsync(); + expect(client.getFeatureFlag('flag'), isTrue); + + server.respond = (_) => PostHogResponse.json({ + 'flags': rich, + 'featureFlags': {'flag': true, 'legacy-only': true}, + 'featureFlagPayloads': {'flag': '"legacy"'}, + }); + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('flag'), rich.isEmpty ? isNull : isFalse); + expect(client.getFeatureFlag('legacy-only'), isNull); + expect(client.getFeatureFlagResult('flag', sendEvent: false)?.payload, + isNull); + }); + } + + test('legacy: malformed flags and payloads do not affect other flags', + () async { + final client = testClient(server); + server.respond = (_) => PostHogResponse.json({ + 'featureFlags': { + 'good': true, + 'bad-value': 1, + 'null-value': null, + 'bad-payload': 'blue', + 'bad-json': true, + }, + 'featureFlagPayloads': { + 'good': '[1,true]', + 'bad-payload': {'decoded': true}, + 'bad-json': '{broken', + }, + }); + + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('good'), isTrue); + expect(client.getFeatureFlagResult('good', sendEvent: false)?.payload, + [1, true]); + expect(client.getFeatureFlag('bad-value'), isNull); + expect(client.getFeatureFlag('null-value'), isNull); + expect(client.getFeatureFlag('bad-payload'), 'blue'); + expect( + client.getFeatureFlagResult('bad-payload', sendEvent: false)?.payload, + isNull); + expect(client.getFeatureFlag('bad-json'), isTrue); + expect(client.getFeatureFlagResult('bad-json', sendEvent: false)?.payload, + isNull); + }); + + test( + 'legacy: a partial response merges and a full response replaces the cache', + () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + server.respond = (_) => PostHogResponse.json({ + 'featureFlags': {'retained': true, 'updated': true}, + 'featureFlagPayloads': {'retained': '"cached"'}, + }); + await client.reloadFeatureFlagsAsync(); + + server.respond = (_) => PostHogResponse.json({ + 'featureFlags': {'updated': false, 'new': 'blue', 'bad': []}, + 'errorsWhileComputingFlags': true, + }); + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('retained'), isTrue); + expect(client.getFeatureFlagResult('retained', sendEvent: false)?.payload, + 'cached'); + expect(client.getFeatureFlag('updated'), isFalse); + expect(client.getFeatureFlag('new'), 'blue'); + expect(queuedProps(storage, 0)[r'$feature_flag_error'], + 'errors_while_computing_flags'); + + server.respond = (_) => PostHogResponse.json({'featureFlags': {}}); + await client.reloadFeatureFlagsAsync(); + expect(client.getFeatureFlag('retained'), isNull); + expect(client.getFeatureFlag('updated'), isNull); + expect(client.getFeatureFlag('new'), isNull); + }); + + test('legacy: request metadata is retained without inventing details', + () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + client.register({'team': 'growth'}); + server.respond = (_) => PostHogResponse.json({ + 'featureFlags': {'flag': true}, + 'featureFlagPayloads': {'flag': '{"enabled":true}'}, + 'requestId': 'legacy-request', + 'evaluatedAt': 123, + 'minimalFlagCalledEvents': true, + }); + + await client.reloadFeatureFlagsAsync(); + expect(client.getFeatureFlag('flag'), isTrue); + + final props = queuedProps(storage, 0); + expect(props[r'$feature_flag_request_id'], 'legacy-request'); + expect(props[r'$feature_flag_evaluated_at'], 123); + expect(props['team'], 'growth'); + expect(props[r'$feature/flag'], isTrue); + for (final property in [ + r'$feature_flag_id', + r'$feature_flag_version', + r'$feature_flag_reason', + r'$feature_flag_has_experiment', + ]) { + expect(props.containsKey(property), isFalse); + } + }); + + test('legacy: quota retains the cache and marks a missing flag', () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + server.respond = (_) => PostHogResponse.json({ + 'featureFlags': {'cached': true}, + }); + await client.reloadFeatureFlagsAsync(); + + server.respond = (_) => PostHogResponse.json({ + 'featureFlags': {'cached': false}, + 'quotaLimited': ['feature_flags'], + }); + await client.reloadFeatureFlagsAsync(); + + expect(client.getFeatureFlag('cached'), isTrue); + expect(client.getFeatureFlag('missing'), isNull); + expect(queuedProps(storage, 1)[r'$feature_flag_error'], 'quota_limited'); + }); +} diff --git a/posthog_flutter/test/core/opt_out_test.dart b/posthog_flutter/test/core/opt_out_test.dart new file mode 100644 index 00000000..661c72c4 --- /dev/null +++ b/posthog_flutter/test/core/opt_out_test.dart @@ -0,0 +1,202 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/file_storage.dart'; +import 'package:posthog_flutter/src/core/persistence.dart'; +import 'package:posthog_flutter/src/posthog_desktop_client.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + late LocalPostHogServer server; + + setUp(() async { + server = await LocalPostHogServer.start(); + }); + + group('Opt-out consent', () { + test('optOut: true blocks events until optIn()', () { + final storage = tempStorage(); + final client = testClient(server, + config: testConfig(optOut: true), storage: storage); + + client.capture('evt'); + + expect(getQueue(storage), isEmpty); + expect(client.optedOut, isTrue); + }); + + test('optIn() re-enables a client created with optOut: true', () { + final storage = tempStorage(); + final client = testClient(server, + config: testConfig(optOut: true), storage: storage); + + client.optIn(); + client.capture('evt'); + + expect(client.optedOut, isFalse); + expect(getQueue(storage), hasLength(1)); + }); + + test('reset() keeps an opt-out for this client, not the next one', () { + final dir = tempDirectory(); + final storage = FileStorage(dir.path); + final client = testClient(server, storage: storage); + + client.optOut(); + client.reset(); + + expect(client.optedOut, isTrue, + reason: 'a logout must not silently re-enable tracking'); + client.capture('evt'); + expect(getQueue(storage), isEmpty); + + client.close(); + final restarted = testClient(server, storage: FileStorage(dir.path)); + expect(restarted.optedOut, isFalse, + reason: 'reset clears the persisted decision, so the next client ' + 'starts from the configured default'); + }); + + test('reset() keeps an opt-in for this client, not the next one', () { + final dir = tempDirectory(); + final storage = FileStorage(dir.path); + final client = testClient(server, + config: testConfig(optOut: true), storage: storage); + + client.optIn(); + client.reset(); + client.capture('evt'); + + expect(getQueue(storage), hasLength(1)); + client.close(); + final restarted = testClient(server, + config: testConfig(optOut: true), storage: FileStorage(dir.path)); + expect(restarted.optedOut, isTrue); + }); + + test('consent fails closed while the store is unreadable', () { + final dir = Directory.systemTemp.createTempSync('posthog_consent'); + final dataFile = '${dir.path}/posthog_data.json'; + addTearDown(() { + chmod('644', dataFile); + dir.deleteSync(recursive: true); + }); + FileStorage(dir.path) + ..setProperty(PostHogPersistedProperty.anonymousId, 'stored-anon') + ..close(); + chmod('000', dataFile); + final storage = FileStorage(dir.path); + final client = + testClient(server, config: testConfig(debug: true), storage: storage); + + client.capture('before opt-in'); + expect(getQueue(storage), isEmpty, + reason: 'with consent unknown, tracking without it would be worse ' + 'than dropping events'); + + final lines = printedLines(() { + client.optIn(); + client.capture('first'); + client.capture('second'); + }); + + expect(lines, contains(contains('"opted_out" in memory only'))); + expect(queuedEvents(storage), ['first', 'second'], + reason: 'an explicit opt-in applies although it cannot be stored'); + expect(queuedMessage(storage, 1)['distinct_id'], + queuedMessage(storage, 0)['distinct_id']); + chmod('644', dataFile); + final stored = FileStorage(dir.path); + expect(stored.getProperty(PostHogPersistedProperty.anonymousId), + 'stored-anon'); + expect( + stored.getProperty(PostHogPersistedProperty.optedOut), isNull); + }, skip: chmodSkip); + + test('a consent decision outlasts the storage becoming readable', () { + final dir = Directory.systemTemp.createTempSync('posthog_consent'); + final dataFile = '${dir.path}/posthog_data.json'; + addTearDown(() { + chmod('644', dataFile); + dir.deleteSync(recursive: true); + }); + FileStorage(dir.path) + ..setProperty(PostHogPersistedProperty.optedOut, false) + ..close(); + chmod('000', dataFile); + final client = testClient(server, storage: FileStorage(dir.path)); + + client.optOut(); + chmod('644', dataFile); + + expect(client.optedOut, isTrue, + reason: 'the opt-out made while the file could not be read wins ' + 'over the stored opt-in'); + }, skip: chmodSkip); + }); + + group('Calls while opted out', () { + late FileStorage storage; + late DesktopPostHog client; + + setUp(() { + storage = tempStorage(); + client = testClient(server, storage: storage); + client.optOut(); + }); + + test('identify, alias and group leave the identity untouched', () async { + final anonymousId = client.getDistinctId(); + + final reloads = await flagsRequestsFor(client, server, () { + client.identify('user-123', userProperties: {'plan': 'pro'}); + client.alias('user-alias'); + client.group('company', 'acme', groupProperties: {'seats': 50}); + }); + + expect(client.getDistinctId(), anonymousId); + for (final key in [ + PostHogPersistedProperty.personMode, + PostHogPersistedProperty.enablePersonProcessing, + PostHogPersistedProperty.props, + PostHogPersistedProperty.personProperties, + PostHogPersistedProperty.groupProperties, + ]) { + expect(storage.getProperty(key), isNull, reason: key.key); + } + expect(reloads, 0); + }); + + test('capture leaves the person state untouched', () { + client.capture('evt', properties: { + r'$set': {'plan': 'pro'}, + }); + + expect( + storage.getProperty( + PostHogPersistedProperty.enablePersonProcessing), + isNull); + expect( + storage.getProperty>( + PostHogPersistedProperty.personProperties), + isNull); + }); + + test('setPersonProperties is not remembered as sent', () async { + final reloads = await flagsRequestsFor( + client, + server, + () => + client.setPersonProperties(userPropertiesToSet: {'plan': 'pro'})); + expect(reloads, 0); + + client.optIn(); + client.setPersonProperties(userPropertiesToSet: {'plan': 'pro'}); + + expect(queuedEvents(storage), [r'$set'], + reason: 'the dropped call must not count as a duplicate'); + }); + }); +} diff --git a/posthog_flutter/test/core/posthog_core_test.dart b/posthog_flutter/test/core/posthog_core_test.dart new file mode 100644 index 00000000..401dd6c6 --- /dev/null +++ b/posthog_flutter/test/core/posthog_core_test.dart @@ -0,0 +1,648 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/file_storage.dart'; +import 'package:posthog_flutter/src/core/persistence.dart'; +import 'package:posthog_flutter/src/posthog_config.dart'; +import 'package:posthog_flutter/src/posthog_desktop_client.dart'; +import 'package:posthog_flutter/src/posthog_flutter_version.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + late LocalPostHogServer server; + + setUp(() async { + server = await LocalPostHogServer.start(); + }); + + group('PostHogCore.capture', () { + late FileStorage storage; + late DesktopPostHog client; + + setUp(() { + storage = tempStorage(); + client = testClient(server, storage: storage); + }); + + test('enqueues the event with its properties and library metadata', () { + client.capture('test_event', properties: {'key': 'value'}); + + expect(getQueue(storage), hasLength(1)); + expect(queuedMessage(storage, 0)['event'], 'test_event'); + final props = queuedProps(storage, 0); + expect(props['key'], 'value'); + expect(props[r'$lib'], postHogFlutterSdkName); + }); + + test('keeps an anonymous user personless', () { + client.capture('test_event'); + + expect(queuedProps(storage, 0)[r'$process_person_profile'], isFalse); + expect(queuedProps(storage, 0)[r'$is_identified'], isFalse); + }); + + for (final key in [r'$set', r'$set_once']) { + test('person properties in $key make an anonymous user person-processed', + () { + client.capture('signed_up', properties: { + key: {'plan': 'pro'}, + }); + client.capture('next_event'); + + expect(queuedProps(storage, 0)[r'$process_person_profile'], isTrue, + reason: 'sending person properties asks for a person profile'); + expect(queuedProps(storage, 1)[r'$process_person_profile'], isTrue); + }); + } + + test('person properties stay personless when personProfiles is never', () { + final storage = tempStorage(); + final client = testClient(server, + config: testConfig(personProfiles: PostHogPersonProfiles.never), + storage: storage); + + client.capture('signed_up', properties: { + r'$set': {'plan': 'pro'}, + }); + + expect(queuedProps(storage, 0)[r'$process_person_profile'], isFalse); + }); + + test(r'$groups apply to that event only', () async { + final reloads = await flagsRequestsFor(client, server, () { + client.capture('invite_sent', properties: { + r'$groups': {'company': 'acme'}, + }); + client.capture('next_event'); + }); + + expect(queuedProps(storage, 0)[r'$groups'], {'company': 'acme'}); + expect(queuedProps(storage, 1).containsKey(r'$groups'), isFalse); + expect(queuedProps(storage, 1)[r'$process_person_profile'], isFalse); + expect(reloads, 0, reason: 'an event-level group is not a group change'); + }); + + test('attaches the session debug properties', () async { + final storage = tempStorage(); + final before = DateTime.now().millisecondsSinceEpoch; + final client = testClient(server, storage: storage); + final after = DateTime.now().millisecondsSinceEpoch; + // Real wait: the session starts with the client, not its first event. + await Future.delayed(const Duration(milliseconds: 10)); + + client.capture('first'); + client.register({r'$sdk_debug_pending_queue_size': 'registered'}); + client.capture('second'); + + final first = queuedProps(storage, 0); + expect(first[r'$sdk_debug_session_start'], + allOf(greaterThanOrEqualTo(before), lessThanOrEqualTo(after))); + expect(first[r'$sdk_debug_current_session_duration'], + greaterThanOrEqualTo(10)); + expect(first[r'$sdk_debug_pending_queue_size'], 0); + expect(queuedProps(storage, 1)[r'$sdk_debug_pending_queue_size'], 1, + reason: 'SDK debug values win over a registered super property'); + }); + }); + + group('PostHogCore.getDistinctId', () { + test('is an anonymous id, generated once and kept across restarts', () { + final dir = tempDirectory(); + final first = testClient(server, storage: FileStorage(dir.path)); + final anonymousId = first.getDistinctId(); + first.close(); + + final restarted = testClient(server, storage: FileStorage(dir.path)); + + expect(anonymousId, isNotEmpty); + expect(restarted.getDistinctId(), anonymousId); + }); + }); + + group('PostHogCore.identify', () { + late FileStorage storage; + late DesktopPostHog client; + + setUp(() { + storage = tempStorage(); + client = testClient(server, storage: storage); + }); + + test('switches the distinct id and marks the person identified', () { + client.identify('user-123'); + + expect(client.getDistinctId(), 'user-123'); + expect(storage.getProperty(PostHogPersistedProperty.personMode), + 'identified'); + }); + + test('is ignored when personProfiles is never', () { + final client = testClient(server, + config: testConfig(personProfiles: PostHogPersonProfiles.never)); + + client.identify('user-123'); + + expect(client.getDistinctId(), isNot('user-123')); + }); + + for (final distinctId in ['', ' ']) { + test('ignores the blank distinct id "$distinctId"', () async { + final anonymousId = client.getDistinctId(); + + final reloads = await flagsRequestsFor( + client, server, () => client.identify(distinctId)); + + expect(client.getDistinctId(), anonymousId); + expect(getQueue(storage), isEmpty); + expect(reloads, 0); + }); + } + + test(r'sends $identify linking the anonymous id, and reloads flags', + () async { + final anonymousId = client.getDistinctId(); + + final reloads = await flagsRequestsFor(client, server, + () => client.identify('user-123', userProperties: {'plan': 'pro'})); + + expect(queuedEvents(storage), [r'$identify']); + expect(queuedMessage(storage, 0)['distinct_id'], 'user-123'); + final props = queuedProps(storage, 0); + expect(props['distinct_id'], 'user-123'); + expect(props[r'$anon_distinct_id'], anonymousId); + expect(props[r'$set'], {'plan': 'pro'}); + expect(reloads, 1); + }); + + test(r'with the anonymous id marks the user identified with a $set', + () async { + final anonymousId = client.getDistinctId(); + + final reloads = await flagsRequestsFor( + client, server, () => client.identify(anonymousId)); + + expect(queuedEvents(storage), [r'$set'], + reason: r'there is no anonymous history to link, so no $identify'); + expect(queuedProps(storage, 0)[r'$is_identified'], isTrue); + expect(queuedProps(storage, 0)[r'$process_person_profile'], isTrue); + expect(reloads, 0, + reason: 'the identified state alone does not affect flags'); + }); + + test('with the anonymous id and properties reloads flags', () async { + final reloads = await flagsRequestsFor( + client, + server, + () => client.identify(client.getDistinctId(), + userProperties: {'plan': 'pro'})); + + expect(queuedProps(storage, 0)[r'$set'], {'plan': 'pro'}); + expect(reloads, 1); + }); + + test(r'with the same id sends new properties once, without reloading flags', + () async { + client.identify('user-123'); + + final reloads = await flagsRequestsFor(client, server, () { + for (var i = 0; i < 2; i++) { + client.identify('user-123', userProperties: {'plan': 'pro'}); + } + }); + + expect(queuedEvents(storage), [r'$identify', r'$set']); + expect( + storage.getProperty>( + PostHogPersistedProperty.personProperties), + {'plan': 'pro'}); + expect(reloads, 0); + }); + + test('with the same id and no properties sends nothing', () { + client.identify('user-123'); + + client.identify('user-123'); + + expect(queuedEvents(storage), [r'$identify']); + }); + + test('with another id while identified keeps the identified user', + () async { + client.identify('user-123'); + + final reloads = await flagsRequestsFor( + client, server, () => client.identify('user-456')); + + expect(client.getDistinctId(), 'user-123'); + expect(queuedEvents(storage), [r'$identify']); + expect(reloads, 0); + }); + }); + + group('Person processing hints', () { + test(r'are on $identify, $create_alias and $groupidentify', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + + client.identify('user-123'); + client.alias('user-alias'); + client.group('company', 'acme'); + + for (final event in [r'$identify', r'$create_alias', r'$groupidentify']) { + final props = queuedPropsOf(storage, event); + expect(props[r'$process_person_profile'], isTrue, reason: event); + expect(props[r'$is_identified'], isTrue, reason: event); + expect(props, contains(r'$sdk_debug_session_start'), reason: event); + } + }); + }); + + group('PostHogCore.reset', () { + test('clears identity but keeps the queue', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + client.identify('user-123'); + client.capture('test_event'); + + client.reset(); + + expect(client.getDistinctId(), isNot('user-123')); + expect(storage.getProperty(PostHogPersistedProperty.personMode), + isNull); + expect(getQueue(storage), isNotEmpty, + reason: 'reset clears identity, not pending events'); + }); + + test('starts a new session', () { + final client = testClient(server); + final sessionId = client.getSessionId(); + + client.reset(); + + expect(client.getSessionId(), isNot(sessionId)); + }); + }); + + group('PostHogCore.getSessionId', () { + test('reuses the session id within the expiration window', () { + final client = testClient(server); + final sessionId = client.getSessionId(); + + expect(sessionId, isNotEmpty); + expect(client.getSessionId(), sessionId); + }); + + test('starts a new session for every client', () { + final dir = tempDirectory(); + final first = testClient(server, storage: FileStorage(dir.path)); + final sessionId = first.getSessionId(); + first.close(); + + final second = testClient(server, storage: FileStorage(dir.path)); + + expect(second.getSessionId(), isNot(sessionId), + reason: 'an app start begins a new session'); + }); + }); + + group('PostHogCore.debug', () { + test('logs every queued event until turned off', () { + final client = testClient(server); + + client.debug(true); + final lines = printedLines(() => client.capture('sign_in')); + client.debug(false); + + expect(lines, contains(allOf(contains('capture'), contains('sign_in')))); + expect(printedLines(() => client.capture('sign_out')), isEmpty); + }); + }); + + group('Super properties', () { + late FileStorage storage; + late DesktopPostHog client; + + setUp(() { + storage = tempStorage(); + client = testClient(server, storage: storage); + }); + + test('registered properties are attached to captured events', () { + client.register({'app_version': '1.0.0'}); + + client.capture('test_event'); + + expect(queuedProps(storage, 0)['app_version'], '1.0.0'); + }); + + test('unregistered properties stop being attached', () { + client.register({'app_version': '1.0.0', 'platform': 'web'}); + + client.unregister('app_version'); + client.capture('test_event'); + + final props = queuedProps(storage, 0); + expect(props['app_version'], isNull); + expect(props['platform'], 'web'); + }); + + test("an event's own value wins over a registered one", () { + client.register({'plan': 'free'}); + + client.capture('test_event', properties: {'plan': 'pro'}); + + expect(queuedProps(storage, 0)['plan'], 'pro'); + }); + }); + + group('PostHogCore.registerForSession', () { + late FileStorage storage; + late DesktopPostHog client; + + setUp(() { + storage = tempStorage(); + client = testClient(server, storage: storage); + }); + + test("win over registered properties, not over the event's own", () { + client.register({'screen': 'registered', 'plan': 'free'}); + client.registerForSession({'screen': 'Checkout', 'plan': 'trial'}); + + client.capture('first'); + client.capture('second', properties: {'plan': 'pro'}); + + expect(queuedProps(storage, 0)['screen'], 'Checkout'); + expect(queuedProps(storage, 0)['plan'], 'trial'); + expect(queuedProps(storage, 1)['plan'], 'pro'); + }); + + test('are dropped by reset', () { + client.registerForSession({'screen': 'Checkout'}); + + client.reset(); + client.capture('after reset'); + + expect(queuedProps(storage, 0), isNot(contains('screen'))); + }); + }); + + group('PostHogCore.setPersonPropertiesForFlags', () { + test('merges consecutive calls instead of replacing', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + + client.setPersonPropertiesForFlags({'role': 'admin'}); + client.setPersonPropertiesForFlags({'plan': 'pro'}); + + expect( + storage.getProperty>( + PostHogPersistedProperty.personProperties), + {'role': 'admin', 'plan': 'pro'}); + }); + }); + + group('PostHogCore.alias', () { + test(r'captures a $create_alias event carrying the alias', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + + client.alias('new-alias'); + + expect(queuedMessage(storage, 0)['event'], r'$create_alias'); + expect(queuedProps(storage, 0)['alias'], 'new-alias'); + }); + + for (final alias in ['', ' ']) { + test('ignores the empty alias "$alias" with a warning', () { + final storage = tempStorage(); + final client = testClient(server, + config: testConfig(debug: true), storage: storage); + + final lines = printedLines(() => client.alias(alias)); + client.capture('next_event'); + + expect(queuedEvents(storage), ['next_event']); + expect(queuedProps(storage, 0)[r'$process_person_profile'], isFalse, + reason: 'an ignored alias does not turn on person processing'); + expect(lines, contains(contains('empty alias'))); + }); + } + }); + + group('PostHogCore.group', () { + late FileStorage storage; + late DesktopPostHog client; + + setUp(() { + storage = tempStorage(); + client = testClient(server, storage: storage); + }); + + test(r'captures a $groupidentify event with the group properties', () { + client.group('company', 'company-123', groupProperties: {'name': 'Acme'}); + + final props = queuedPropsOf(storage, r'$groupidentify'); + expect(props[r'$group_type'], 'company'); + expect(props[r'$group_key'], 'company-123'); + expect(props[r'$group_set'], {'name': 'Acme'}); + }); + + test(r'captures $groupidentify without group properties too', () { + client.group('company', 'company-123'); + + final props = queuedPropsOf(storage, r'$groupidentify'); + expect(props[r'$group_type'], 'company'); + expect(props[r'$group_key'], 'company-123'); + }); + + test('group properties reach the flags reload of a group change', () async { + client.group('company', 'company-123', groupProperties: {'seats': 50}); + + final body = await server.waitForFlagsRequest(0); + expect(body['groups'], {'company': 'company-123'}); + expect(body['group_properties'], { + 'company': {'seats': 50}, + }); + }); + + test('the same group key again does not reload flags', () async { + final reloads = await flagsRequestsFor(client, server, () { + client.group('company', 'company-123'); + client.group('company', 'company-123', groupProperties: {'seats': 50}); + }); + + expect(queuedEvents(storage), [r'$groupidentify', r'$groupidentify']); + expect(reloads, 1); + expect( + storage.getProperty>( + PostHogPersistedProperty.groupProperties), + { + 'company': {'seats': 50}, + }); + }); + + // (group type, group key) + const emptyGroups = <(String, String)>[ + ('', 'company-123'), + ('company', ''), + (' ', 'company-123'), + ]; + + for (final (groupType, groupKey) in emptyGroups) { + test('ignores the group "$groupType" "$groupKey" with a warning', + () async { + final storage = tempStorage(); + final client = testClient(server, + config: testConfig(debug: true), storage: storage); + + late List lines; + final reloads = await flagsRequestsFor(client, server, () { + lines = printedLines(() => client + .group(groupType, groupKey, groupProperties: {'seats': 50})); + }); + client.capture('next_event'); + + expect(queuedEvents(storage), ['next_event']); + expect(queuedProps(storage, 0), isNot(contains(r'$groups'))); + expect( + storage + .getProperty(PostHogPersistedProperty.groupProperties), + isNull); + expect(reloads, 0); + expect(lines, contains(contains('empty group type or key'))); + }); + } + }); + + group('Property serialization', () { + late FileStorage storage; + late DesktopPostHog client; + + setUp(() { + storage = tempStorage(); + client = + testClient(server, config: testConfig(debug: true), storage: storage); + }); + + test('drops null-valued object members, keeping array positions', () { + // The example of the capture spec. + client.capture('Nullable Properties', + properties: jsonDecode('{"test":null,"nested":{"drop":null},' + '"items":["1",null,2,{"drop":null},[null]],"empty":"",' + '"zero":0,"enabled":false,"literal":"null","emptyArray":[]}')); + + final props = queuedProps(storage, 0); + expect(props, isNot(contains('test'))); + expect(props['nested'], {}); + expect(props['items'], [ + '1', + null, + 2, + {}, + [null], + ]); + expect(props['empty'], ''); + expect(props['zero'], 0); + expect(props['enabled'], isFalse); + expect(props['literal'], 'null'); + expect(props['emptyArray'], isEmpty); + }); + + test('converts the values JSON cannot represent', () { + final at = DateTime.utc(2024, 5, 6, 7, 8, 9); + + client.capture('evt', properties: { + 'at': at, + 'local_at': at.toLocal(), + 'link': Uri.parse('https://posthog.com/docs'), + 'plan': const _Plan('pro'), + 'ratio': double.nan, + 'counts': {1: 'one'}, + }); + + final props = queuedProps(storage, 0); + expect(props['at'], '2024-05-06T07:08:09.000Z'); + expect(props['local_at'], '2024-05-06T07:08:09.000Z'); + expect(props['link'], 'https://posthog.com/docs'); + expect(props['plan'], {'name': 'pro'}, + reason: 'jsonEncode supports toJson(), so the event must too'); + expect(props['ratio'], 'NaN'); + expect(props['counts'], {'1': 'one'}); + expect(() => jsonEncode(getQueue(storage)), returnsNormally); + }); + + test('sends a value without a JSON form as its string, with a warning', () { + final lines = printedLines(() => client + .capture('evt', properties: {'timeout': const Duration(seconds: 3)})); + + expect(queuedProps(storage, 0)['timeout'], '0:00:03.000000'); + expect(lines, contains(contains('Duration'))); + }); + + test('a map that contains itself does not break the capture', () { + final cyclic = {'name': 'loop'}; + cyclic['self'] = cyclic; + + expect(() => client.capture('evt', properties: {'cyclic': cyclic}), + returnsNormally); + + final sent = queuedProps(storage, 0)['cyclic'] as Map; + expect(sent['name'], 'loop'); + expect(sent['self'], isA()); + }); + + test('a super property and an event holding a DateTime are stored', () { + final dir = tempDirectory(); + final storage = FileStorage(dir.path); + final client = testClient(server, storage: storage); + + client.register({'since': DateTime.utc(2024)}); + client.capture('evt', properties: {'at': DateTime.utc(2024, 2)}); + + expect( + FileStorage(dir.path).getProperty>( + PostHogPersistedProperty.props), + {'since': '2024-01-01T00:00:00.000Z'}); + // Read back from its file. + expect(queuedProps(storage, 0)['at'], '2024-02-01T00:00:00.000Z'); + }); + }); + + group('Input tolerance', () { + // User-supplied maps arrive loosely typed or straight from jsonDecode; + // neither shape may throw or lose the event. + final cases = <(String, void Function(DesktopPostHog client))>[ + ( + r'capture with jsonDecode properties including $groups', + (client) => client.capture('evt', + properties: jsonDecode('{"\$groups": {"team": "core"}, "n": 1}') + as Map), + ), + ( + 'setPersonProperties with a nested empty map', + (client) => + client.setPersonProperties(userPropertiesToSet: {'meta': {}}), + ), + ]; + + for (final (description, act) in cases) { + test('$description enqueues without throwing', () { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + + expect(() => act(client), returnsNormally); + expect(getQueue(storage), isNotEmpty); + }); + } + }); +} + +class _Plan { + const _Plan(this.name); + + final String name; + + Map toJson() => {'name': name}; +} diff --git a/posthog_flutter/test/core/queue_flush_test.dart b/posthog_flutter/test/core/queue_flush_test.dart new file mode 100644 index 00000000..53db1d7a --- /dev/null +++ b/posthog_flutter/test/core/queue_flush_test.dart @@ -0,0 +1,774 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/file_storage.dart'; +import 'package:posthog_flutter/src/core/persistence.dart'; +import 'package:posthog_flutter/src/core/posthog_core_stateless.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + group('PostHogCore.flush', () { + late LocalPostHogServer server; + + setUp(() async { + server = await LocalPostHogServer.start(); + }); + + test('sends queued events in one batch and empties the queue', () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + + client.capture('sign_in'); + client.capture('sign_out'); + await client.flush(); + + expect(server.batchRequests.single.eventNames, ['sign_in', 'sign_out']); + expect(getQueue(storage), isEmpty); + }); + + test('removes sent events by identity when the queue head shifts', + () async { + final storage = tempStorage(); + final client = testClient(server, + config: testConfig(flushAt: 2, maxBatchSize: 2, maxQueueSize: 2), + storage: storage); + + final inFlight = Completer(); + final release = Completer(); + server.respond = (request) async { + if (!inFlight.isCompleted) { + inFlight.complete(); + await release.future; + } + return const PostHogResponse(HttpStatus.ok); + }; + + client.capture('first'); + client.capture('second'); // reaches flushAt, the batch takes off + await inFlight.future; + // Overflow while the batch is in flight: 'first' is evicted, so the + // batch and the queue no longer line up positionally. + client.capture('third'); + release.complete(); + await client.flush(); + + expect( + [for (final request in server.batchRequests) request.eventNames], + [ + ['first', 'second'], + ['third'], + ], + reason: "removal must match by uuid: positional removal would delete " + "'third' from the queue instead of the already-evicted 'first'", + ); + expect(getQueue(storage), isEmpty); + }); + + test('halves the batch size on HTTP 413 until the server accepts', + () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + // This server rejects anything bigger than two events per request. + server.respond = (request) => PostHogResponse(request.events.length > 2 + ? HttpStatus.requestEntityTooLarge + : HttpStatus.ok); + + for (var i = 0; i < 8; i++) { + client.capture('event_$i'); + } + await client.flush(); + + final batches = [ + for (final request in server.batchRequests) request.eventNames, + ]; + expect(batches.map((batch) => batch.length).toList(), [8, 4, 2, 2, 2, 2]); + expect(batches.skip(2).expand((batch) => batch).toList(), + [for (var i = 0; i < 8; i++) 'event_$i']); + expect(getQueue(storage), isEmpty); + }); + + test('shares one network cycle between concurrent calls', () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + final release = Completer(); + server.respond = (request) async { + await release.future; + return const PostHogResponse(HttpStatus.ok); + }; + + client.capture('evt'); + final first = client.flush(); + final second = client.flush(); + release.complete(); + await Future.wait([first, second]); + + expect(server.batchRequests, hasLength(1)); + expect(getQueue(storage), isEmpty); + }); + + test('hard HTTP 400 is not retried and drops the batch', () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + server.respond = (_) => const PostHogResponse(HttpStatus.badRequest); + + client.capture('evt'); + await expectLater(client.flush(), throwsA(isA())); + + expect(server.batchRequests, hasLength(1)); + expect(getQueue(storage), isEmpty); + }); + + test('retries a transient failure three times, three seconds apart', () { + final api = InProcessPostHogApi() + ..respond = (_) => const PostHogResponse(HttpStatus.serviceUnavailable); + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, storage: storage); + Object? error; + + client.capture('evt'); + client.flush().catchError((Object e) { + error = e; + }); + + async.elapse(Duration.zero); + expect(api.batchRequests, hasLength(1)); + for (final attempts in [2, 3, 4]) { + async.elapse(const Duration(milliseconds: 2999)); + expect(api.batchRequests, hasLength(attempts - 1)); + async.elapse(const Duration(milliseconds: 1)); + expect(api.batchRequests, hasLength(attempts)); + } + expect(error, isA()); + expect(getQueue(storage), hasLength(1)); + }); + }); + + // (status, reason the batch stays queued) + const transientStatuses = <(int, String)>[ + (408, 'the server timed out'), + (429, 'the server asks to slow down'), + (500, 'the server failed'), + ( + 301, + 'a POST redirect is not followed, so the batch never reached ' + 'ingestion' + ), + ( + 307, + 'a POST redirect is not followed, so the batch never reached ' + 'ingestion' + ), + ]; + + for (final (status, reason) in transientStatuses) { + test('HTTP $status keeps the batch queued after the retries', () { + final api = InProcessPostHogApi() + ..respond = (_) => PostHogResponse(status); + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, storage: storage); + Object? error; + + client.capture('evt'); + client.flush().catchError((Object e) { + error = e; + }); + async.elapse(const Duration(seconds: 9)); + + expect(error, isA()); + expect(api.batchRequests, hasLength(4)); + expect(getQueue(storage), hasLength(1), reason: reason); + }); + }); + } + + test('a failed flush re-arms the periodic timer', () { + final api = InProcessPostHogApi(); + var online = false; + api.respond = (_) => online + ? const PostHogResponse(HttpStatus.ok) + : const PostHogResponse.dropped(); + final storage = tempStorage(); + fakeAsync((async) { + testClient(api, storage: storage).capture('evt'); + + // The periodic flush and its retries fail. + async.elapse(const Duration(seconds: 39)); + expect(api.batchRequests, hasLength(4)); + + online = true; + // Only a re-armed timer can produce the next, successful attempt. + async.elapse(const Duration(seconds: 30)); + + expect(api.batchRequests, hasLength(5)); + expect(getQueue(storage), isEmpty); + }); + }); + + test('the timer firing while a flush is in flight stays re-armable', () { + final api = InProcessPostHogApi(); + final storage = tempStorage(); + fakeAsync((async) { + final release = Completer(); + var online = false; + api.respond = (_) async { + await release.future; + return PostHogResponse( + online ? HttpStatus.ok : HttpStatus.serviceUnavailable); + }; + final client = testClient(api, + config: testConfig(flushInterval: const Duration(milliseconds: 20)), + storage: storage); + + client.capture('first'); + client.flush().catchError((Object _) {}); + // Arms the timer again while the flush above is still in flight, and + // lets it fire during that flush. + client.capture('second'); + async.elapse(const Duration(milliseconds: 60)); + release.complete(); + // The flush and its retries fail. + async.elapse(const Duration(seconds: 9)); + expect(api.batchRequests, hasLength(4)); + + online = true; + async.elapse(const Duration(milliseconds: 20)); + + expect(api.batchRequests.last.eventNames, ['first', 'second'], + reason: 'only a re-armed timer sends the queue again'); + expect(getQueue(storage), isEmpty); + }); + }); + }); + + group('Retry-After', () { + for (final status in [429, 503]) { + test('HTTP $status pauses every send path and resumes the queue', () { + final api = InProcessPostHogApi(); + final storage = tempStorage(); + fakeAsync((async) { + api.respond = + (_) => PostHogResponse(status, headers: {'Retry-After': '120'}); + final client = + testClient(api, config: testConfig(flushAt: 2), storage: storage); + Object? error; + client.capture('first'); + client.flush().catchError((Object e) { + error = e; + }); + async.elapse(Duration.zero); + + expect(error, isA(), + reason: 'flush should not wait for the server pause to expire'); + final original = api.events.single; + api.respond = (_) => const PostHogResponse(HttpStatus.ok); + client.capture('second'); + client.flush(); + client.flush(); + async.elapse(const Duration(seconds: 119)); + expect(api.batchRequests, hasLength(1)); + expect(queuedEvents(storage), ['first', 'second']); + + async.elapse(const Duration(seconds: 1)); + expect(api.batchRequests, hasLength(2)); + expect(api.batchRequests.last.eventNames, ['first', 'second']); + expect(api.batchRequests.last.events.first['uuid'], original['uuid']); + expect(api.batchRequests.last.events.first['timestamp'], + original['timestamp']); + expect(getQueue(storage), isEmpty); + + client.capture('third'); + client.capture('fourth'); + async.elapse(Duration.zero); + expect(api.batchRequests, hasLength(3), + reason: 'the previous pause does not apply after a success'); + }); + }); + } + + for (final header in [ + 'invalid', + '-1', + '0', + '', + 'Sun, 06 Nov 1994 08:49:37 GMT', + ]) { + test('ignores Retry-After "$header" and keeps three retries', () { + final api = InProcessPostHogApi() + ..respond = (_) => PostHogResponse(HttpStatus.serviceUnavailable, + headers: {'retry-after': header}); + fakeAsync((async) { + final client = testClient(api); + Object? error; + client.capture('event'); + client.flush().catchError((Object e) { + error = e; + }); + async.elapse(const Duration(seconds: 9)); + expect(error, isA()); + expect(api.batchRequests, hasLength(4)); + }); + }); + } + + test('accepts an HTTP date and does not send before it', () { + final api = InProcessPostHogApi() + ..respond = + (_) => PostHogResponse(HttpStatus.serviceUnavailable, headers: { + 'retry-after': HttpDate.format( + DateTime.now().toUtc().add(const Duration(seconds: 120))) + }); + fakeAsync((async) { + final client = testClient(api); + Object? error; + client.capture('event'); + client.flush().catchError((Object e) { + error = e; + }); + async.elapse(Duration.zero); + expect(error, isA()); + api.respond = (_) => const PostHogResponse(HttpStatus.ok); + async.elapse(const Duration(seconds: 118)); + expect(api.batchRequests, hasLength(1)); + async.elapse(const Duration(seconds: 2)); + expect(api.batchRequests, hasLength(2)); + }); + }); + + test('does not retry a permanent error because of the header', () { + final api = InProcessPostHogApi() + ..respond = (_) => const PostHogResponse(HttpStatus.badRequest, + headers: {'retry-after': '120'}); + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, storage: storage); + client.capture('invalid'); + client.flush().catchError((Object _) {}); + async.elapse(Duration.zero); + expect(getQueue(storage), isEmpty); + api.respond = (_) => const PostHogResponse(HttpStatus.ok); + client.capture('valid'); + client.flush(); + async.elapse(Duration.zero); + expect(api.eventNames, ['invalid', 'valid']); + }); + }); + + test('keeps the pause when consent changes and identity resets', () { + final api = InProcessPostHogApi() + ..respond = (_) => const PostHogResponse(HttpStatus.tooManyRequests, + headers: {'retry-after': '120'}); + fakeAsync((async) { + final client = testClient(api); + final previousId = client.getDistinctId(); + client.capture('before'); + client.flush().catchError((Object _) {}); + async.elapse(Duration.zero); + api.respond = (_) => const PostHogResponse(HttpStatus.ok); + client.optOut(); + client.optIn(); + client.reset(); + final nextId = client.getDistinctId(); + expect(nextId, isNot(previousId)); + client.capture('after'); + client.flush(); + async.elapse(const Duration(seconds: 119)); + expect(api.batchRequests, hasLength(1)); + async.elapse(const Duration(seconds: 1)); + final events = api.batchRequests.last.events; + expect(events.map((event) => event['event']), ['before', 'after']); + expect(events[0]['distinct_id'], previousId); + expect(events[1]['distinct_id'], nextId); + }); + }); + + test('does not send automatically when the interval is zero', () { + final api = InProcessPostHogApi() + ..respond = (_) => const PostHogResponse(HttpStatus.tooManyRequests, + headers: {'retry-after': '120'}); + fakeAsync((async) { + final client = testClient(api, + config: testConfig(flushAt: 1, flushInterval: Duration.zero)); + client.capture('first'); + async.elapse(Duration.zero); + api.respond = (_) => const PostHogResponse(HttpStatus.ok); + client.capture('second'); + client.flush(); + async.elapse(const Duration(seconds: 120)); + expect(api.batchRequests, hasLength(1)); + client.flush(); + client.flush(); + async.elapse(Duration.zero); + expect(api.batchRequests, hasLength(2)); + expect(api.batchRequests.last.eventNames, ['first', 'second']); + }); + }); + + test('a batch pause does not change flags request retries', () { + final api = InProcessPostHogApi() + ..respond = (_) => const PostHogResponse(HttpStatus.serviceUnavailable, + headers: {'retry-after': '120'}); + fakeAsync((async) { + final client = testClient(api); + client.capture('event'); + client.flush().catchError((Object _) {}); + async.elapse(Duration.zero); + api.respond = (_) => const PostHogResponse(HttpStatus.badGateway, + headers: {'retry-after': '120'}); + client.reloadFeatureFlagsAsync(); + async.elapse(const Duration(milliseconds: 300)); + expect(api.flagsRequests, hasLength(2)); + expect(api.batchRequests, hasLength(1)); + api.respond = (_) => const PostHogResponse(HttpStatus.ok); + async.elapse(const Duration(milliseconds: 119700)); + expect(api.batchRequests, hasLength(2)); + }); + }); + + test('Retry-After in flags does not pause the event queue', () { + final api = InProcessPostHogApi() + ..respond = (request) => request.isFlags + ? const PostHogResponse(HttpStatus.tooManyRequests, + headers: {'retry-after': '120'}) + : const PostHogResponse(HttpStatus.ok); + fakeAsync((async) { + final client = testClient(api); + client.reloadFeatureFlagsAsync(); + async.elapse(Duration.zero); + client.capture('event'); + client.flush(); + async.elapse(Duration.zero); + expect(api.flagsRequests, hasLength(1)); + expect(api.batchRequests.single.eventNames, ['event']); + }); + }); + + test('a new rate limit starts a new pause with the minimum delay', () { + final api = InProcessPostHogApi() + ..respond = (_) => const PostHogResponse(HttpStatus.tooManyRequests, + headers: {'retry-after': '60'}); + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, storage: storage); + client.capture('event'); + client.flush().catchError((Object _) {}); + async.elapse(Duration.zero); + api.respond = (_) => const PostHogResponse(HttpStatus.tooManyRequests, + headers: {'retry-after': '1'}); + async.elapse(const Duration(seconds: 60)); + expect(api.batchRequests, hasLength(2)); + api.respond = (_) => const PostHogResponse(HttpStatus.ok); + client.flush(); + async.elapse(const Duration(milliseconds: 2999)); + expect(api.batchRequests, hasLength(2)); + async.elapse(const Duration(milliseconds: 1)); + expect(api.batchRequests, hasLength(3)); + expect(getQueue(storage), isEmpty); + }); + }); + + test('close cancels the timer and preserves the queue for the next launch', + () { + final api = InProcessPostHogApi() + ..respond = (_) => const PostHogResponse(HttpStatus.tooManyRequests, + headers: {'retry-after': '120'}); + final dir = tempDirectory(); + fakeAsync((async) { + final client = testClient(api, storage: FileStorage(dir.path)); + client.capture('event'); + client.flush().catchError((Object _) {}); + async.elapse(Duration.zero); + client.close(); + expect(async.pendingTimers, isEmpty); + async.elapse(const Duration(seconds: 120)); + expect(api.batchRequests, hasLength(1)); + + api.respond = (_) => const PostHogResponse(HttpStatus.ok); + testClient(api, storage: FileStorage(dir.path)).flush(); + async.elapse(Duration.zero); + expect(api.batchRequests.last.eventNames, ['event']); + }); + }); + + test('a response after close does not create a new timer', () { + final api = InProcessPostHogApi(); + fakeAsync((async) { + final response = Completer(); + api.respond = (_) => response.future; + final client = testClient(api); + client.capture('event'); + client.flush().catchError((Object _) {}); + async.elapse(Duration.zero); + client.close(); + response.complete(const PostHogResponse(HttpStatus.tooManyRequests, + headers: {'retry-after': '120'})); + async.flushMicrotasks(); + expect(async.pendingTimers, isEmpty); + expect(api.batchRequests, hasLength(1)); + }); + }); + }); + + group('Automatic flushing', () { + test('flushes as soon as the queue reaches flushAt', () async { + final server = await LocalPostHogServer.start(); + final storage = tempStorage(); + final client = + testClient(server, config: testConfig(flushAt: 3), storage: storage); + + client.capture('one'); + client.capture('two'); + client.capture('three'); + await server.waitForEvent('three'); + await client.flush(); + + expect(server.batchRequests.first.eventNames, ['one', 'two', 'three'], + reason: 'a flush that took off before flushAt would have sent the ' + 'first events on their own'); + expect(getQueue(storage), isEmpty); + }); + + test('drops the oldest events once the queue exceeds maxQueueSize', () { + final api = InProcessPostHogApi() + ..respond = (_) => const PostHogResponse(HttpStatus.serviceUnavailable); + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, + config: testConfig( + flushAt: 1, maxQueueSize: 100, flushInterval: Duration.zero), + storage: storage); + // Delivery keeps failing, so every event stays queued and only the + // overflow rule decides which ones survive. + + for (var i = 0; i < 102; i++) { + client.capture('event_$i'); + } + async.elapse(const Duration(seconds: 9)); + + expect( + queuedEvents(storage), [for (var i = 2; i <= 101; i++) 'event_$i']); + }); + }); + + test('keeps at most maxQueueSize events, also below flushAt', () { + final api = InProcessPostHogApi(); + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, + config: testConfig(flushAt: 20, maxQueueSize: 2, debug: true), + storage: storage); + + final lines = printedLines(() { + for (var i = 0; i < 3; i++) { + client.capture('event_$i'); + } + }); + + expect(queuedEvents(storage), ['event_1', 'event_2']); + expect(lines, contains(contains('Queue is full'))); + + // The queue cannot reach flushAt: the periodic flush sends it. + async.elapse(const Duration(seconds: 30)); + expect(api.batchRequests.single.eventNames, ['event_1', 'event_2']); + expect(getQueue(storage), isEmpty); + }); + }); + + test('sends batches of at most maxBatchSize, also below flushAt', () { + final api = InProcessPostHogApi(); + final storage = tempStorage(); + fakeAsync((async) { + final client = testClient(api, + config: testConfig(flushAt: 5, maxBatchSize: 2), storage: storage); + + for (var i = 0; i < 5; i++) { + client.capture('event_$i'); + } + async.elapse(Duration.zero); + + expect([ + for (final request in api.batchRequests) request.eventNames + ], [ + ['event_0', 'event_1'], + ['event_2', 'event_3'], + ['event_4'], + ]); + expect(getQueue(storage), isEmpty); + }); + }); + }); + + group('Queue persistence', () { + late LocalPostHogServer server; + + setUp(() async { + server = await LocalPostHogServer.start(); + }); + + test('flushes events captured by a previous process from FileStorage', + () async { + final dir = tempDirectory(); + testClient(server, storage: FileStorage(dir.path)) + ..capture('sign_in') + ..capture('sign_out') + ..close(); + + final storage = FileStorage(dir.path); + await testClient(server, storage: storage).flush(); + + expect(server.batchRequests.single.eventNames, ['sign_in', 'sign_out']); + expect(getQueue(storage), isEmpty); + expect(Directory('${dir.path}/posthog_queue').listSync(), isEmpty, + reason: 'delivered events must also leave the on-disk queue'); + }); + + test('sends events of a previous run without waiting for a capture', + () async { + final dir = tempDirectory(); + testClient(server, storage: FileStorage(dir.path)) + ..capture('sign_in') + ..close(); + + final storage = FileStorage(dir.path); + final client = testClient(server, + config: testConfig(flushInterval: const Duration(milliseconds: 20)), + storage: storage); + await server.waitForEvent('sign_in'); + await client.flush(); + + expect(server.batchRequests.single.eventNames, ['sign_in']); + expect(getQueue(storage), isEmpty); + }); + }); + + group('PostHogCore.close', () { + late LocalPostHogServer server; + + setUp(() async { + server = await LocalPostHogServer.start(); + }); + + test('aborts a batch in flight without retrying it; the batch stays queued', + () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + final received = Completer(); + server.respond = (_) { + received.complete(); + return Completer().future; + }; + client.capture('evt'); + final inFlight = client.flush(); + await received.future; + + client.close(); + + // Well within the request timeout, so only an aborted request fails. + await expectLater(inFlight.timeout(const Duration(seconds: 5)), + throwsA(isA())); + expect(server.batchRequests, hasLength(1), reason: 'it is not retried'); + expect(getQueue(storage), hasLength(1), + reason: 'the next client using the storage sends it'); + }); + + test('closes its connections to PostHog', () async { + final client = testClient(server); + client.capture('evt'); + await client.flush(); + expect(server.openConnections, 1, + reason: 'the client keeps its connection alive between requests'); + + client.close(); + + await server.connectionsClosed(); + }); + + test('ignores calls made afterwards', () async { + final storage = tempStorage(); + final client = + testClient(server, config: testConfig(debug: true), storage: storage); + final distinctId = client.getDistinctId(); + client.close(); + + late Future reload; + final lines = printedLines(() { + client.capture('After Close'); + client.identify('user-1'); + client.register({'plan': 'pro'}); + reload = client.reloadFeatureFlagsAsync(); + }); + await reload; + await client.flush(); + + expect(getQueue(storage), isEmpty); + expect(client.getDistinctId(), distinctId); + expect( + storage.getProperty(PostHogPersistedProperty.props), isNull); + expect(server.requests, isEmpty); + expect(lines, contains(contains('closed'))); + }); + + test('stops the periodic flush', () { + final api = InProcessPostHogApi(); + fakeAsync((async) { + final client = testClient(api); + client.capture('before close'); + + client.close(); + + expect(async.pendingTimers, isEmpty); + }); + }); + + test('a flush that fails after close does not re-arm the timer', () { + final api = InProcessPostHogApi(); + fakeAsync((async) { + final release = Completer(); + api.respond = (_) async { + await release.future; + return const PostHogResponse(HttpStatus.serviceUnavailable); + }; + final client = testClient(api); + Object? error; + client.capture('evt'); + client.flush().catchError((Object e) { + error = e; + }); + async.elapse(Duration.zero); + + client.close(); + release.complete(); + async.flushMicrotasks(); + + expect(error, isA()); + expect(api.batchRequests, hasLength(1)); + expect(async.pendingTimers, isEmpty, + reason: 'a re-armed timer would flush a client whose resources ' + 'are already released'); + }); + }); + + test('releases the storage directory for the next client', () async { + final dir = tempDirectory(); + testClient(server, storage: FileStorage(dir.path)) + ..capture('queued before close') + ..close(); + + final next = testClient(server, storage: FileStorage(dir.path)); + next.identify('next-user'); + await next.flush(); + + expect( + FileStorage(dir.path) + .getProperty(PostHogPersistedProperty.distinctId), + 'next-user'); + expect(server.batchRequests.single.eventNames, + ['queued before close', r'$identify']); + }); + }); +} diff --git a/posthog_flutter/test/core/requests_test.dart b/posthog_flutter/test/core/requests_test.dart new file mode 100644 index 00000000..ff259b26 --- /dev/null +++ b/posthog_flutter/test/core/requests_test.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/posthog_core_stateless.dart'; +import 'package:posthog_flutter/src/posthog_flutter_version.dart'; + +import '../posthog_api_fake.dart'; +import 'test_client.dart'; + +void main() { + late LocalPostHogServer server; + + setUp(() async { + server = await LocalPostHogServer.start(); + }); + + group('Requests to PostHog', () { + test('post gzip-compressed JSON with the SDK as the user agent', () async { + final client = testClient(server); + + client.capture('desktop_event', properties: {'source': 'test'}); + await client.flush(); + + final request = server.batchRequests.single; + expect(request.headers['content-encoding'], 'gzip'); + expect(request.headers['content-type'], 'application/json'); + expect(request.headers['user-agent'], + '$postHogFlutterSdkName/$postHogFlutterVersion'); + expect(request.body['api_key'], 'k'); + expect(request.eventNames, ['desktop_event']); + }); + + test('report an error status together with the response body', () async { + final client = testClient(server); + server.respond = (_) => + const PostHogResponse(HttpStatus.badRequest, body: 'invalid batch'); + + client.capture('evt'); + + await expectLater( + client.flush(), + throwsA(isA() + .having((e) => e.status, 'status', 400) + .having((e) => e.responseBody, 'responseBody', 'invalid batch'))); + }); + + test('leave a redirect unfollowed', () async { + final target = await LocalPostHogServer.start(); + final client = testClient(server); + server.respond = (_) => PostHogResponse(HttpStatus.seeOther, + location: '${target.url}/flags/?v=2&config=true'); + + // A /flags request is not retried after a redirect, unlike a batch. + await client.reloadFeatureFlagsAsync(); + + expect(server.flagsRequests, hasLength(1)); + expect(target.requests, isEmpty); + }); + + test('respects HTTP Retry-After without waiting inside flush', () async { + final client = testClient(server); + server.respond = (_) => const PostHogResponse(HttpStatus.tooManyRequests, + headers: {'Retry-After': '120'}); + client.capture('event'); + + await expectLater(client.flush().timeout(const Duration(seconds: 2)), + throwsA(isA())); + await client.flush(); + + expect(server.batchRequests, hasLength(1)); + }); + + test('report a refused connection as a connection error', () async { + final closed = await LocalPostHogServer.start(); + await closed.close(); + final storage = tempStorage(); + final client = testClient(closed, storage: storage); + + await client.reloadFeatureFlagsAsync(); + client.getFeatureFlag('beta-ui'); + + expect( + queuedPropsOf( + storage, r'$feature_flag_called')[r'$feature_flag_error'], + 'connection_error'); + }); + }); +} diff --git a/posthog_flutter/test/core/session_manager_test.dart b/posthog_flutter/test/core/session_manager_test.dart new file mode 100644 index 00000000..6ecca1dc --- /dev/null +++ b/posthog_flutter/test/core/session_manager_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/session_manager.dart'; + +void main() { + const minute = 60 * 1000; + const hour = 60 * minute; + + group('PostHogSessionManager', () { + test('starts a session when it is created', () { + final before = DateTime.now().millisecondsSinceEpoch; + final sessions = PostHogSessionManager(); + final after = DateTime.now().millisecondsSinceEpoch; + + expect(sessions.id, isNotEmpty); + expect(sessions.startedAt, inInclusiveRange(before, after)); + }); + + test('keeps the session while events come within 30 minutes', () { + final sessions = PostHogSessionManager(); + final start = sessions.startedAt; + final id = sessions.id; + + expect(sessions.touch(start + 30 * minute), id); + expect(sessions.touch(start + 60 * minute), id); + }); + + test('an event after 30 minutes without one starts a new session', () { + final sessions = PostHogSessionManager(); + final start = sessions.startedAt; + final id = sessions.touch(start); + + final next = sessions.touch(start + 30 * minute + 1); + + expect(next, isNot(id)); + expect(sessions.id, next); + expect(sessions.startedAt, start + 30 * minute + 1); + }); + + test('a session ends 24 hours after it started, however active', () { + final sessions = PostHogSessionManager(); + final start = sessions.startedAt; + final id = sessions.id; + + for (var now = start; now <= start + 24 * hour; now += 20 * minute) { + expect(sessions.touch(now), id); + } + + expect(sessions.touch(start + 24 * hour + 1), isNot(id)); + }); + + test('restart starts a new session at once', () { + final sessions = PostHogSessionManager(); + final id = sessions.id; + + sessions.restart(); + + expect(sessions.id, isNot(id)); + }); + }); +} diff --git a/posthog_flutter/test/core/test_client.dart b/posthog_flutter/test/core/test_client.dart new file mode 100644 index 00000000..62a96f13 --- /dev/null +++ b/posthog_flutter/test/core/test_client.dart @@ -0,0 +1,132 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/file_storage.dart'; +import 'package:posthog_flutter/src/posthog_config.dart'; +import 'package:posthog_flutter/src/posthog_desktop_client.dart'; + +import '../posthog_api_fake.dart'; + +/// Config tuned for tests: no preload unless asked for, and a flushAt high +/// enough that flushes only happen when a test asks for them. +PostHogConfig testConfig({ + bool preloadFeatureFlags = false, + int flushAt = 100, + int maxBatchSize = 100, + int maxQueueSize = 1000, + Duration flushInterval = const Duration(seconds: 30), + bool optOut = false, + bool debug = false, + PostHogPersonProfiles personProfiles = PostHogPersonProfiles.identifiedOnly, + PostHogBootstrapConfig? bootstrap, +}) => + PostHogConfig('k') + ..flushAt = flushAt + ..maxBatchSize = maxBatchSize + ..maxQueueSize = maxQueueSize + ..flushInterval = flushInterval + ..preloadFeatureFlags = preloadFeatureFlags + ..optOut = optOut + ..debug = debug + ..personProfiles = personProfiles + ..bootstrap = bootstrap; + +/// A new temporary directory, deleted at the end of the test. +Directory tempDirectory() { + final dir = Directory.systemTemp.createTempSync('posthog_core'); + addTearDown(() => dir.deleteSync(recursive: true)); + return dir; +} + +/// A storage in a new temporary directory. +FileStorage tempStorage() => FileStorage(tempDirectory().path); + +/// A client of [api] with [config], closed at the end of the test. It keeps +/// its state in [storage], a new temporary directory by default, and +/// attaches [context] and [timezone] to its events. +DesktopPostHog testClient( + PostHogApiFake api, { + PostHogConfig? config, + FileStorage? storage, + Map context = const {}, + String? timezone, +}) { + final client = api.connect( + () => DesktopPostHog( + (config ?? testConfig())..host = api.url, + storage: storage ?? tempStorage(), + staticContext: context, + timezone: timezone, + ), + ); + addTearDown(client.close); + return client; +} + +/// The number of `/flags/` requests [client] sends for [action]. +/// +/// A reload asked for runs once the one in flight is done, so one before +/// [action] and one after it bracket the requests of [action]; they are +/// left out of the count. +Future flagsRequestsFor( + DesktopPostHog client, + PostHogApiFake api, + void Function() action, +) async { + await client.reloadFeatureFlagsAsync(); + final before = api.flagsRequests.length; + action(); + await client.reloadFeatureFlagsAsync(); + return api.flagsRequests.length - before - 1; +} + +/// The queued event messages, oldest first. +List> getQueue(FileStorage storage) => [ + for (final queued in storage.queue.peek(storage.queue.length)) + queued.event, + ]; + +/// The queued event message at [index]. +Map queuedMessage(FileStorage storage, int index) => + getQueue(storage)[index]; + +/// The properties of the queued event message at [index]. +Map queuedProps(FileStorage storage, int index) { + return queuedMessage(storage, index)['properties'] as Map; +} + +/// The names of the queued events, oldest first. +List queuedEvents(FileStorage storage) => + [for (final message in getQueue(storage)) message['event']]; + +/// The properties of the only queued event named [event]. +Map queuedPropsOf(FileStorage storage, String event) { + final message = + getQueue(storage).singleWhere((message) => message['event'] == event); + return message['properties'] as Map; +} + +/// Skip reason for tests that simulate IO failures with POSIX permissions. +final chmodSkip = + Platform.isWindows ? 'simulates IO failures via POSIX chmod' : false; + +/// Applies [mode] via chmod, failing the test if the probe cannot be set up. +void chmod(String mode, String path) { + final result = Process.runSync('chmod', [mode, path]); + expect(result.exitCode, 0, + reason: 'chmod $mode must succeed for the probe to prove anything'); +} + +/// Runs [body] and returns the lines it logged through [debugPrint]. +List printedLines(void Function() body) { + final lines = []; + final previous = debugPrint; + debugPrint = (message, {wrapWidth}) => lines.add(message ?? ''); + try { + body(); + } finally { + debugPrint = previous; + } + return lines; +} diff --git a/posthog_flutter/test/core/uuid_test.dart b/posthog_flutter/test/core/uuid_test.dart new file mode 100644 index 00000000..b0412620 --- /dev/null +++ b/posthog_flutter/test/core/uuid_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/core/uuid.dart'; + +void main() { + group('generateUuidV7', () { + test('produces the RFC 9562 version-7 layout', () { + final uuid = generateUuidV7(); + + expect(uuid, hasLength(36)); + // Version nibble 7, variant bits 10. + expect( + uuid, + matches(RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'))); + }); + + test('starts with the current Unix time in milliseconds', () { + final before = DateTime.now().millisecondsSinceEpoch; + final uuid = generateUuidV7(); + final after = DateTime.now().millisecondsSinceEpoch; + + final millis = + int.parse(uuid.replaceAll('-', '').substring(0, 12), radix: 16); + expect(millis, inInclusiveRange(before, after)); + }); + + test('produces unique values', () { + final uuids = List.generate(1000, (_) => generateUuidV7()); + + expect(uuids.toSet(), hasLength(1000)); + }); + + test('sorts lexicographically across milliseconds', () async { + final earlier = generateUuidV7(); + // The version-7 prefix is a millisecond timestamp, so ordering only + // shows up once the clock has actually ticked. + await Future.delayed(const Duration(milliseconds: 2)); + final later = generateUuidV7(); + + expect(later.compareTo(earlier), greaterThan(0)); + }); + + test('sorts lexicographically within a millisecond', () { + final uuids = List.generate(10000, (_) => generateUuidV7()); + final timestamps = {for (final uuid in uuids) uuid.substring(0, 13)}; + + expect(timestamps.length, lessThan(uuids.length), + reason: 'the probe needs ids that share a millisecond'); + for (var i = 1; i < uuids.length; i++) { + expect(uuids[i].compareTo(uuids[i - 1]), greaterThan(0), + reason: '${uuids[i]} was generated after ${uuids[i - 1]}'); + } + }); + }); +} diff --git a/posthog_flutter/test/event_shape_snapshot_test.dart b/posthog_flutter/test/event_shape_snapshot_test.dart index 6b85bd91..db8f09f5 100644 --- a/posthog_flutter/test/event_shape_snapshot_test.dart +++ b/posthog_flutter/test/event_shape_snapshot_test.dart @@ -280,7 +280,7 @@ Future _expectSnapshot(String name, List calls) async { expect( '$formatted\n', - await snapshotFile.readAsString(), + (await snapshotFile.readAsString()).replaceAll('\r\n', '\n'), reason: 'Update this explicit fixture only after reviewing the shape diff. ' 'Run with --dart-define=UPDATE_EVENT_SHAPE_SNAPSHOTS=true to accept it.', ); diff --git a/posthog_flutter/test/native_occlusion_episode_test.dart b/posthog_flutter/test/native_occlusion_episode_test.dart index 84e65b48..e298b9d7 100644 --- a/posthog_flutter/test/native_occlusion_episode_test.dart +++ b/posthog_flutter/test/native_occlusion_episode_test.dart @@ -221,6 +221,23 @@ void main() { ); }); + final windowsAndLinux = TargetPlatformVariant( + {TargetPlatform.windows, TargetPlatform.linux}, + ); + testWidgets('stays off the native replay channel on Windows and Linux', + (tester) async { + await setupPosthog(replayConfig(captureNativeScreens: false)); + await pumpReplayWidget(tester); + await tester.pump(const Duration(seconds: 2)); + + PostHogInternalEvents.sessionRecordingActive.value = false; + PostHogInternalEvents.sessionRecordingActive.value = true; + await tester.pump(const Duration(seconds: 2)); + await unmountAndFlush(tester); + + expect(recordedCalls, isEmpty); + }, variant: windowsAndLinux); + testWidgets('resumes capture after recording is toggled off and on', (tester) async { await setupPosthog(replayConfig(captureNativeScreens: false)); diff --git a/posthog_flutter/test/posthog_api_fake.dart b/posthog_flutter/test/posthog_api_fake.dart new file mode 100644 index 00000000..e4e3ca31 --- /dev/null +++ b/posthog_flutter/test/posthog_api_fake.dart @@ -0,0 +1,317 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// A request a client sent to the PostHog API. +class PostHogRequest { + PostHogRequest._(this.path, this.headers, this.body); + + factory PostHogRequest._decode( + String path, + Map headers, + List bytes, + ) { + if (bytes.isEmpty) return PostHogRequest._(path, headers, const {}); + final json = utf8.decode( + headers['content-encoding'] == 'gzip' ? gzip.decode(bytes) : bytes, + ); + return PostHogRequest._( + path, + headers, + Map.from(jsonDecode(json) as Map), + ); + } + + /// The path of the endpoint, such as `/batch/` or `/flags/`. + final String path; + + /// The headers, by lowercase name. + final Map headers; + + /// The JSON body, decompressed and decoded. + final Map body; + + bool get isBatch => path == '/batch/'; + + bool get isFlags => path == '/flags/'; + + /// The events of a `/batch/` request, oldest first. + List> get events => [ + for (final event in body['batch']! as List) + Map.from(event as Map), + ]; + + /// The names of the events of a `/batch/` request, oldest first. + List get eventNames => [for (final event in events) event['event']]; +} + +/// How the PostHog API answers a request. +class PostHogResponse { + const PostHogResponse(this.status, + {this.body = '{"status": 1}', this.location, this.headers = const {}}) + : isDropped = false; + + /// A success with [json] as its body. + PostHogResponse.json(Object? json) + : this(HttpStatus.ok, body: jsonEncode(json)); + + /// No answer: the connection is closed instead. + const PostHogResponse.dropped() + : status = 0, + body = '', + location = null, + headers = const {}, + isDropped = true; + + final int status; + final String body; + + /// Where a redirect points. + final String? location; + + final Map headers; + + final bool isDropped; +} + +typedef PostHogResponder = FutureOr Function( + PostHogRequest request, +); + +/// A fake of the PostHog API: records every request and answers it through +/// [respond]. +abstract class PostHogApiFake { + /// Answers the requests; a success by default. It may take its time, for + /// a request that stays in flight. + PostHogResponder respond = (_) => const PostHogResponse(HttpStatus.ok); + + /// The requests received, oldest first. + final requests = []; + + final _waiters = <({bool Function() isReady, Completer done})>[]; + + /// The host that reaches this API. + String get url; + + /// Runs [create], so that the clients it creates send their requests to + /// this API. + T connect(T Function() create); + + List get batchRequests => [ + for (final request in requests) + if (request.isBatch) request + ]; + + List get flagsRequests => [ + for (final request in requests) + if (request.isFlags) request + ]; + + /// Every event received, oldest first. + List> get events => + [for (final request in batchRequests) ...request.events]; + + List get eventNames => [for (final event in events) event['event']]; + + /// Completes with the first event named [name] once it has arrived. + Future> waitForEvent(String name) async { + await _waitUntil(() => events.any((event) => event['event'] == name)); + return events.firstWhere((event) => event['event'] == name); + } + + /// Completes with the body of the `/flags/` request at [index] once it + /// has arrived. + Future> waitForFlagsRequest(int index) async { + await _waitUntil(() => flagsRequests.length > index); + return flagsRequests[index].body; + } + + Future _answer(PostHogRequest request) async { + requests.add(request); + _waiters.removeWhere((waiter) { + if (!waiter.isReady()) return false; + waiter.done.complete(); + return true; + }); + return respond(request); + } + + Future _waitUntil(bool Function() isReady) { + if (isReady()) return Future.value(); + final done = Completer(); + _waiters.add((isReady: isReady, done: done)); + return done.future; + } +} + +/// The PostHog API on a local port. +class LocalPostHogServer extends PostHogApiFake { + LocalPostHogServer._(this._server) : url = 'http://127.0.0.1:${_server.port}'; + + final HttpServer _server; + + /// Where the server listens, also once it is closed. + @override + final String url; + + /// Starts a server, closed at the end of the test. + static Future start() async { + final server = LocalPostHogServer._( + await HttpServer.bind(InternetAddress.loopbackIPv4, 0), + ); + server._server.listen(server._serve); + addTearDown(server.close); + return server; + } + + @override + T connect(T Function() create) => create(); + + /// The connections clients hold open to this server. + int get openConnections => _server.connectionsInfo().total; + + /// Completes once the clients have closed every connection to this server. + Future connectionsClosed() async { + // Real wait: the server learns about a closed socket asynchronously. + for (var i = 0; openConnections > 0; i++) { + if (i == 100) fail('the client kept $openConnections connection(s)'); + await Future.delayed(const Duration(milliseconds: 10)); + } + } + + Future close() => _server.close(force: true); + + Future _serve(HttpRequest request) async { + try { + final bytes = await request.fold>( + [], + (all, chunk) => all..addAll(chunk), + ); + final headers = {}; + request.headers.forEach( + (name, values) => headers[name] = values.join(', '), + ); + final response = await _answer( + PostHogRequest._decode(request.uri.path, headers, bytes), + ); + if (response.isDropped) { + (await request.response.detachSocket(writeHeaders: false)).destroy(); + return; + } + request.response.statusCode = response.status; + response.headers.forEach(request.response.headers.set); + final location = response.location; + if (location != null) { + request.response.headers.set(HttpHeaders.locationHeader, location); + } + request.response.write(response.body); + await request.response.close(); + } on IOException { + // The client went away before the answer, as closing it makes it do. + } + } +} + +/// A PostHog API that the clients created by [connect] reach without a +/// socket. For tests that control time with fake_async, which cannot wait +/// for a socket. +class InProcessPostHogApi extends PostHogApiFake { + /// A host that never resolves: requests do not leave the process. + @override + String get url => 'http://posthog.invalid'; + + @override + T connect(T Function() create) => HttpOverrides.runZoned( + create, + createHttpClient: (_) => _InProcessHttpClient(this), + ); +} + +class _InProcessHttpClient implements HttpClient { + _InProcessHttpClient(this._api); + + final PostHogApiFake _api; + + @override + Future postUrl(Uri url) async => + _InProcessRequest(_api, url); + + @override + void close({bool force = false}) {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _InProcessRequest implements HttpClientRequest { + _InProcessRequest(this._api, this.uri); + + final PostHogApiFake _api; + final _body = []; + + @override + final Uri uri; + + @override + final _InProcessHeaders headers = _InProcessHeaders(); + + @override + bool followRedirects = true; + + @override + int contentLength = -1; + + @override + void add(List data) => _body.addAll(data); + + @override + Future close() async { + final response = await _api._answer( + PostHogRequest._decode(uri.path, headers.values, _body), + ); + if (response.isDropped) { + throw const HttpException( + 'Connection closed before full header was received', + ); + } + return _InProcessResponse(response); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _InProcessHeaders implements HttpHeaders { + final values = {}; + + @override + void set(String name, Object value, {bool preserveHeaderCase = false}) { + values[name.toLowerCase()] = '$value'; + } + + @override + String? value(String name) => values[name.toLowerCase()]; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _InProcessResponse extends StreamView> + implements HttpClientResponse { + _InProcessResponse(PostHogResponse response) + : statusCode = response.status, + super(Stream.value(utf8.encode(response.body))) { + response.headers.forEach(headers.set); + } + + @override + final int statusCode; + + @override + final _InProcessHeaders headers = _InProcessHeaders(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/posthog_flutter/test/posthog_desktop_app_info_test.dart b/posthog_flutter/test/posthog_desktop_app_info_test.dart new file mode 100644 index 00000000..b80f81ac --- /dev/null +++ b/posthog_flutter/test/posthog_desktop_app_info_test.dart @@ -0,0 +1,95 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_desktop_app_info.dart'; + +void main() { + group('DesktopAppInfo.fromLinuxBundle', () { + late Directory bundle; + + setUp(() { + bundle = Directory.systemTemp.createTempSync('posthog_bundle'); + addTearDown(() => bundle.deleteSync(recursive: true)); + }); + + void writeVersionJson(String content) { + File('${bundle.path}/data/flutter_assets/version.json') + ..createSync(recursive: true) + ..writeAsStringSync(content); + } + + test('reads the version.json bundled next to the executable', () { + writeVersionJson( + '{"app_name":"example_app","version":"1.2.3",' + '"build_number":"4","package_name":"example_app"}', + ); + + final info = DesktopAppInfo.fromLinuxBundle('${bundle.path}/example_app'); + + expect(info.name, 'example_app'); + expect(info.version, '1.2.3'); + expect(info.build, '4'); + }); + + test('leaves out what the pubspec does not declare', () { + writeVersionJson('{"app_name":"example_app","package_name":"x"}'); + + final info = DesktopAppInfo.fromLinuxBundle('${bundle.path}/example_app'); + + expect(info.name, 'example_app'); + expect(info.version, isNull); + expect(info.build, isNull); + }); + }); + + group('DesktopAppInfo.fromPlatform', () { + test('leaves out what the running executable does not record', () { + // The tests run in flutter_tester, which bundles no version.json. + final info = DesktopAppInfo.fromPlatform(); + + expect(info.name, isNull); + expect(info.version, isNull); + expect(info.build, isNull); + }, testOn: '!windows'); + }); + + group('DesktopAppInfo.fromVersionResource', () { + test('splits the product version into the version and the build', () { + final info = DesktopAppInfo.fromVersionResource( + productName: 'example_app', + productVersion: '1.2.3+4', + ); + + expect(info.name, 'example_app'); + expect(info.version, '1.2.3'); + expect(info.build, '4'); + }); + + test('keeps a pre-release in the version', () { + final info = DesktopAppInfo.fromVersionResource( + productVersion: '2.0.0-beta.1+17', + ); + + expect(info.version, '2.0.0-beta.1'); + expect(info.build, '17'); + }); + + test('has no build for a version without one', () { + final info = DesktopAppInfo.fromVersionResource(productVersion: '1.2.3'); + + expect(info.version, '1.2.3'); + expect(info.build, isNull); + }); + + test('treats empty strings as missing', () { + final info = DesktopAppInfo.fromVersionResource( + productName: '', + productVersion: '', + ); + + expect(info.name, isNull); + expect(info.version, isNull); + expect(info.build, isNull); + }); + }); +} diff --git a/posthog_flutter/test/posthog_desktop_context_test.dart b/posthog_flutter/test/posthog_desktop_context_test.dart new file mode 100644 index 00000000..5c71c4df --- /dev/null +++ b/posthog_flutter/test/posthog_desktop_context_test.dart @@ -0,0 +1,93 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_desktop_app_info.dart'; +import 'package:posthog_flutter/src/posthog_desktop_context.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('collectDesktopContext', () { + test('collects app and os context', () { + final context = collectDesktopContext( + const DesktopAppInfo(name: 'TestApp', version: '1.2.3', build: '42'), + ); + + expect(context[r'$app_name'], 'TestApp'); + expect(context[r'$app_version'], '1.2.3'); + expect(context[r'$app_build'], 42); + expect(context[r'$device_type'], 'Desktop'); + // Host-dependent values: only their shape is stable across runners. + expect(context[r'$os_name'], anyOf('Windows', 'Linux')); + expect(context[r'$os_version'], isA()); + }); + + test('keeps a build that is not a number as a string', () { + final context = collectDesktopContext( + const DesktopAppInfo(build: '1.2.3'), + ); + + expect(context[r'$app_build'], '1.2.3'); + }); + + test('omits the app values the build did not record', () { + final context = collectDesktopContext(const DesktopAppInfo()); + + expect(context, isNot(contains(r'$app_name'))); + expect(context, isNot(contains(r'$app_version'))); + expect(context, isNot(contains(r'$app_build'))); + }); + }); + + group('extractOsVersion', () { + const versionByBanner = { + '"Windows 10 Pro" 10.0 (Build 19043)': '10.0.19043', + 'Linux 5.11.0-1018-gcp #20~20.04.1-Ubuntu SMP Fri Sep 3 01:01:37 ' + 'UTC 2021': '5.11.0', + }; + + versionByBanner.forEach((banner, version) { + test('extracts $version from $banner', () { + expect(extractOsVersion(banner), version); + }); + }); + + test('returns a banner with no recognizable version unchanged', () { + expect(extractOsVersion('unknown os'), 'unknown os'); + }); + }); + + group('parseBuildNumber', () { + const buildByString = { + '42': 42, + '042': 42, + '1.2.3': '1.2.3', + '0x10': '0x10', + '42-beta': '42-beta', + }; + + buildByString.forEach((build, expected) { + test('parses "$build" as $expected', () { + expect(parseBuildNumber(build), expected); + }); + }); + }); + + group('languageCodeOf', () { + test('reports the language without the region', () { + expect(languageCodeOf(const Locale('en', 'US')), 'en'); + expect( + languageCodeOf( + const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'), + ), + 'zh', + ); + }); + + test('omits locales that name no language', () { + expect(languageCodeOf(const Locale.fromSubtags()), isNull); + expect(languageCodeOf(const Locale('C')), isNull); + expect(languageCodeOf(const Locale('POSIX')), isNull); + }); + }); +} diff --git a/posthog_flutter/test/posthog_desktop_exception_steps_test.dart b/posthog_flutter/test/posthog_desktop_exception_steps_test.dart new file mode 100644 index 00000000..0789ef9c --- /dev/null +++ b/posthog_flutter/test/posthog_desktop_exception_steps_test.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_desktop_exception_steps.dart'; + +void main() { + int bytesOf(Map step) => + utf8.encode(jsonEncode(step)).length; + + List messagesOf(ExceptionStepsBuffer buffer) => + [for (final step in buffer.steps) step[r'$message']]; + + group('ExceptionStepsBuffer', () { + test('records the message, the call time and the properties', () { + final buffer = ExceptionStepsBuffer(maxBytes: 32768); + final before = DateTime.now().toUtc(); + + buffer.add('User tapped Checkout', properties: {'screen': 'cart'}); + + final after = DateTime.now().toUtc(); + final step = buffer.steps.single; + expect(step[r'$message'], 'User tapped Checkout'); + expect(step['screen'], 'cart'); + + final timestamp = step[r'$timestamp']! as String; + expect( + timestamp, + matches(RegExp(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$')), + ); + final recordedAt = DateTime.parse(timestamp); + expect( + recordedAt.millisecondsSinceEpoch, + inInclusiveRange( + before.millisecondsSinceEpoch, + after.millisecondsSinceEpoch, + ), + ); + }); + + test('ignores the reserved keys of the properties', () { + final buffer = ExceptionStepsBuffer(maxBytes: 32768); + + buffer.add('step', properties: { + r'$message': 'spoofed', + r'$timestamp': 'spoofed', + 'kept': true, + }); + + final step = buffer.steps.single; + expect(step[r'$message'], 'step'); + expect(step[r'$timestamp'], isNot('spoofed')); + expect(step['kept'], isTrue); + }); + + test('normalizes values like event properties', () { + final buffer = ExceptionStepsBuffer(maxBytes: 32768); + final date = DateTime.utc(2026, 1, 2); + + buffer.add('step', properties: { + 'date': date, + 'nested': {'kept': 1, 'dropped': null}, + }); + + final step = buffer.steps.single; + expect(step['date'], date.toString()); + expect(step['nested'], {'kept': 1}); + }); + + test('keeps a step with a number JSON cannot represent, as its string', () { + final buffer = ExceptionStepsBuffer(maxBytes: 32768); + + buffer.add('step', properties: { + 'ratio': double.nan, + 'limits': [double.infinity, double.negativeInfinity], + }); + + final step = buffer.steps.single; + expect(step['ratio'], 'NaN'); + expect(step['limits'], ['Infinity', '-Infinity']); + }); + + test('evicts the oldest steps to stay within the budget', () { + final probe = ExceptionStepsBuffer(maxBytes: 32768)..add('step 1'); + final buffer = ExceptionStepsBuffer( + maxBytes: bytesOf(probe.steps.single) * 2, + ); + + buffer + ..add('step 1') + ..add('step 2') + ..add('step 3'); + + expect(messagesOf(buffer), ['step 2', 'step 3']); + }); + + test('rejects a step larger than the budget and keeps the others', () { + final buffer = ExceptionStepsBuffer(maxBytes: 200); + + buffer + ..add('small') + ..add('large', properties: {'payload': 'x' * 500}); + + expect(messagesOf(buffer), ['small']); + }); + + test('measures the budget in UTF-8 bytes', () { + final probe = ExceptionStepsBuffer(maxBytes: 32768)..add('a' * 10); + // Same number of characters, 10 more bytes. + final buffer = ExceptionStepsBuffer( + maxBytes: bytesOf(probe.steps.single) + 5, + ); + + buffer.add('é' * 10); + + expect(buffer.steps, isEmpty); + }); + + test('ignores an empty message', () { + final buffer = ExceptionStepsBuffer(maxBytes: 32768); + + buffer.add(''); + + expect(buffer.steps, isEmpty); + }); + }); +} diff --git a/posthog_flutter/test/posthog_desktop_lifecycle_test.dart b/posthog_flutter/test/posthog_desktop_lifecycle_test.dart new file mode 100644 index 00000000..89366e52 --- /dev/null +++ b/posthog_flutter/test/posthog_desktop_lifecycle_test.dart @@ -0,0 +1,260 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_desktop_lifecycle.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory storageDir; + late List<({String event, Map? properties})> captured; + late int flushes; + + // The message the Windows and Linux embedders send when window focus or + // visibility changes; the binding expands it into single-step transitions. + Future moveApp(AppLifecycleState state) => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + SystemChannels.lifecycle.name, + SystemChannels.lifecycle.codec.encodeMessage(state.toString()), + (_) {}, + ); + + setUp(() async { + storageDir = Directory.systemTemp.createTempSync('posthog_lifecycle'); + addTearDown(() => storageDir.deleteSync(recursive: true)); + captured = []; + flushes = 0; + // Every test starts with the window visible but not focused. + await moveApp(AppLifecycleState.inactive); + }); + + DesktopAppLifecycle createLifecycle({ + String? version = '1.0.0', + String? build = '100', + bool captureEvents = true, + bool withBinding = true, + }) { + final lifecycle = DesktopAppLifecycle( + binding: withBinding ? WidgetsBinding.instance : null, + storageDirectory: storageDir.path, + version: version, + build: build, + captureEvents: captureEvents, + capture: (event, properties) => + captured.add((event: event, properties: properties)), + flush: () async => flushes++, + ); + addTearDown(lifecycle.dispose); + return lifecycle; + } + + /// Starts a lifecycle for a previous launch of the app, then forgets what + /// it captured. + void launchPreviously({String? version = '1.0.0', String? build = '100'}) { + createLifecycle(version: version, build: build) + ..start() + ..dispose(); + captured.clear(); + } + + List eventNames() => [for (final call in captured) call.event]; + + Map? propertiesOf(String event) => + captured.firstWhere((call) => call.event == event).properties; + + group('install and update', () { + test('the first launch captures Application Installed', () { + createLifecycle().start(); + + expect(eventNames(), ['Application Installed']); + expect(propertiesOf('Application Installed'), { + 'version': '1.0.0', + 'build': 100, + }); + }); + + test('a new build captures Application Updated', () { + launchPreviously(); + + createLifecycle(version: '1.1.0', build: '110').start(); + + expect(eventNames(), ['Application Updated']); + expect(propertiesOf('Application Updated'), { + 'version': '1.1.0', + 'build': 110, + 'previous_version': '1.0.0', + 'previous_build': 100, + }); + }); + + test('the same build captures nothing, even with a new version', () { + launchPreviously(); + + createLifecycle(version: '1.0.1').start(); + + expect(captured, isEmpty); + }); + + test('without a build the version is compared', () { + launchPreviously(build: null); + + createLifecycle(version: '1.1.0', build: null).start(); + + expect(eventNames(), ['Application Updated']); + expect(propertiesOf('Application Updated'), { + 'version': '1.1.0', + 'previous_version': '1.0.0', + }); + }); + + test('a build that is not a number is reported as a string', () { + createLifecycle(build: '1.2.3').start(); + + expect(propertiesOf('Application Installed')?['build'], '1.2.3'); + }); + + test('without a version or build nothing is captured or recorded', () { + createLifecycle(version: null, build: null).start(); + + expect(captured, isEmpty); + expect(storageDir.listSync(), isEmpty); + }); + + test('the version is recorded while the events are off', () { + createLifecycle(captureEvents: false).start(); + + createLifecycle().start(); + + expect(captured, isEmpty); + }); + + test('a record that cannot be read is replaced without an event', () { + File('${storageDir.path}${Platform.pathSeparator}posthog_app_version.json') + .writeAsStringSync('not json'); + + createLifecycle().start(); + expect(captured, isEmpty); + + createLifecycle(build: '101').start(); + expect(eventNames(), ['Application Updated']); + }); + }); + + group('opened and backgrounded', () { + test('an app already active at setup is opened as a fresh launch', + () async { + await moveApp(AppLifecycleState.resumed); + + createLifecycle().start(captureOpenedIfActive: true); + + expect(eventNames(), ['Application Installed', 'Application Opened']); + expect(propertiesOf('Application Opened'), { + 'from_background': false, + 'version': '1.0.0', + 'build': 100, + }); + }); + + test('an app that becomes active later is opened then', () async { + createLifecycle().start(); + expect(eventNames(), ['Application Installed']); + + await moveApp(AppLifecycleState.resumed); + + expect(eventNames(), ['Application Installed', 'Application Opened']); + expect(propertiesOf('Application Opened')?['from_background'], isFalse); + }); + + test('resigning active backgrounds the app once and flushes', () async { + launchPreviously(); + await moveApp(AppLifecycleState.resumed); + createLifecycle().start(captureOpenedIfActive: true); + captured.clear(); + + await moveApp(AppLifecycleState.inactive); + await moveApp(AppLifecycleState.hidden); + + expect(eventNames(), ['Application Backgrounded']); + expect(propertiesOf('Application Backgrounded'), isNull); + expect(flushes, 1); + + await moveApp(AppLifecycleState.resumed); + + expect(eventNames(), ['Application Backgrounded', 'Application Opened']); + expect(propertiesOf('Application Opened'), {'from_background': true}); + }); + + test('events stop while stopped but the queue is still flushed', () async { + launchPreviously(); + await moveApp(AppLifecycleState.resumed); + final lifecycle = createLifecycle()..start(); + captured.clear(); + + lifecycle.stop(); + await moveApp(AppLifecycleState.inactive); + await moveApp(AppLifecycleState.resumed); + + expect(captured, isEmpty); + expect(flushes, 1); + }); + + test('a restart reports the next activation as a fresh launch', () async { + launchPreviously(); + await moveApp(AppLifecycleState.resumed); + final lifecycle = createLifecycle()..start(captureOpenedIfActive: true); + captured.clear(); + + lifecycle + ..stop() + ..start(); + expect(captured, isEmpty); + + await moveApp(AppLifecycleState.inactive); + await moveApp(AppLifecycleState.resumed); + + expect(eventNames(), ['Application Opened']); + expect(propertiesOf('Application Opened'), { + 'from_background': false, + 'version': '1.0.0', + 'build': 100, + }); + }); + + test('with the events off only the flush happens', () async { + await moveApp(AppLifecycleState.resumed); + createLifecycle(captureEvents: false).start(); + + await moveApp(AppLifecycleState.inactive); + await moveApp(AppLifecycleState.resumed); + + expect(captured, isEmpty); + expect(flushes, 1); + }); + + test('without a binding only install and update are captured', () async { + await moveApp(AppLifecycleState.resumed); + + createLifecycle(withBinding: false).start(); + await moveApp(AppLifecycleState.inactive); + + expect(eventNames(), ['Application Installed']); + expect(flushes, 0); + }); + + test('dispose stops listening to the app lifecycle', () async { + createLifecycle() + ..start() + ..dispose(); + captured.clear(); + + await moveApp(AppLifecycleState.resumed); + await moveApp(AppLifecycleState.inactive); + + expect(captured, isEmpty); + expect(flushes, 0); + }); + }); +} diff --git a/posthog_flutter/test/posthog_desktop_storage_test.dart b/posthog_flutter/test/posthog_desktop_storage_test.dart new file mode 100644 index 00000000..de012ac5 --- /dev/null +++ b/posthog_flutter/test/posthog_desktop_storage_test.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_desktop_storage.dart'; + +void main() { + final sep = Platform.pathSeparator; + + group('DesktopStorage.appDirectory', () { + // The application data directory: APPDATA on Windows, XDG_DATA_HOME on + // Linux. + const environment = {'APPDATA': '/data', 'XDG_DATA_HOME': '/data'}; + + test('is named after the executable in the application data directory', () { + expect( + DesktopStorage.appDirectory( + environment, + executable: '/opt/example/example_app', + ), + '/data${sep}posthog${sep}example_app', + ); + }); + + test('leaves out the .exe extension', () { + expect( + DesktopStorage.appDirectory( + environment, + executable: r'C:\Program Files\Example\Example.EXE', + ), + '/data${sep}posthog${sep}Example', + ); + }); + + test('replaces characters that do not belong in a directory name', () { + expect( + DesktopStorage.appDirectory( + environment, + executable: '/opt/example/example app', + ), + '/data${sep}posthog${sep}example_app', + ); + }); + + test('is under ~/.local/share without XDG_DATA_HOME', () { + expect( + DesktopStorage.appDirectory( + {'HOME': '/home/user'}, + executable: '/opt/example/example_app', + ), + '/home/user/.local/share/posthog/example_app', + ); + }, testOn: '!windows'); + + test('is in the temporary directory without an application data one', () { + expect( + DesktopStorage.appDirectory( + const {}, + executable: '/opt/example/example_app', + ), + '${Directory.systemTemp.path}${sep}posthog${sep}example_app', + ); + }); + }); + + group('DesktopStorage.projectDirectory', () { + test('is named after the project token in the app directory', () { + expect( + DesktopStorage.projectDirectory('/data/posthog/example', 'phc_1'), + '/data/posthog/example${sep}phc_1', + ); + }); + + test('never leaves the app directory', () { + expect( + DesktopStorage.projectDirectory('/data/posthog/example', '..'), + '/data/posthog/example${sep}default', + ); + expect( + DesktopStorage.projectDirectory('/data/posthog/example', '../../x'), + '/data/posthog/example$sep.._.._x', + ); + }); + }); +} diff --git a/posthog_flutter/test/posthog_desktop_time_zone_test.dart b/posthog_flutter/test/posthog_desktop_time_zone_test.dart new file mode 100644 index 00000000..ca0be7b4 --- /dev/null +++ b/posthog_flutter/test/posthog_desktop_time_zone_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_desktop_time_zone.dart'; + +void main() { + group('DesktopTimeZone.read', () { + test('reads the zone TZ names', () { + expect(DesktopTimeZone.read({'TZ': 'Europe/Berlin'}), 'Europe/Berlin'); + }); + + test('reads /etc/localtime when TZ names no zone', () { + expect( + DesktopTimeZone.read({'TZ': 'CET-1CEST,M3.5.0,M10.5.0/3'}), + DesktopTimeZone.read(const {}), + ); + }, testOn: '!windows'); + + test('Windows reads the system IANA time zone', () { + final zone = DesktopTimeZone.read(const {}); + expect(zone, isNotNull); + expect(zone, isNot('Etc/Unknown')); + expect(DesktopTimeZone.fromTzVariable(zone), zone); + }, testOn: 'windows'); + }); + + group('DesktopTimeZone.fromTzVariable', () { + const zoneByValue = { + 'Europe/Berlin': 'Europe/Berlin', + ':Europe/Berlin': 'Europe/Berlin', + 'America/Argentina/Buenos_Aires': 'America/Argentina/Buenos_Aires', + 'America/Port-au-Prince': 'America/Port-au-Prince', + 'UTC': 'UTC', + 'Etc/GMT+10': 'Etc/GMT+10', + 'GMT0': 'GMT0', + 'EST5EDT': 'EST5EDT', + 'posix/Asia/Tokyo': 'Asia/Tokyo', + ':/usr/share/zoneinfo/Asia/Tokyo': 'Asia/Tokyo', + }; + + zoneByValue.forEach((value, zone) { + test('reads $zone from "$value"', () { + expect(DesktopTimeZone.fromTzVariable(value), zone); + }); + }); + + const noZone = [ + '', + 'CET-1CEST,M3.5.0,M10.5.0/3', + 'JST-9', + '<+03>-3', + ':/etc/localtime', + ]; + + for (final value in noZone) { + test('reads no zone from "$value"', () { + expect(DesktopTimeZone.fromTzVariable(value), isNull); + }); + } + + test('reads no zone without the variable', () { + expect(DesktopTimeZone.fromTzVariable(null), isNull); + }); + }); + + group('DesktopTimeZone.fromZoneInfoPath', () { + test('reads the zone after the zoneinfo directory', () { + expect( + DesktopTimeZone.fromZoneInfoPath('/usr/share/zoneinfo/Europe/Berlin'), + 'Europe/Berlin', + ); + expect( + DesktopTimeZone.fromZoneInfoPath( + '/nix/store/abc-tzdata-2024a/share/zoneinfo/Etc/UTC', + ), + 'Etc/UTC', + ); + }); + + test('reads no zone from a file outside a zoneinfo directory', () { + expect(DesktopTimeZone.fromZoneInfoPath('/etc/localtime'), isNull); + }); + }); +} diff --git a/posthog_flutter/test/posthog_desktop_transport_test.dart b/posthog_flutter/test/posthog_desktop_transport_test.dart new file mode 100644 index 00000000..1b0fbb46 --- /dev/null +++ b/posthog_flutter/test/posthog_desktop_transport_test.dart @@ -0,0 +1,109 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/posthog_desktop_app_info.dart'; +import 'package:posthog_flutter/src/posthog_flutter_desktop.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; +import 'package:posthog_flutter/src/posthog_flutter_version.dart'; + +import 'posthog_api_fake.dart'; + +class _DesktopTransportBinding extends BindingBase + with SchedulerBinding, ServicesBinding {} + +void main() { + _DesktopTransportBinding(); + + late Directory appDirectory; + late LocalPostHogServer server; + late PosthogFlutterPlatformInterface previousPlatform; + + setUp(() async { + appDirectory = Directory.systemTemp.createTempSync('posthog_transport'); + server = await LocalPostHogServer.start(); + previousPlatform = PosthogFlutterPlatformInterface.instance; + PosthogFlutterPlatformInterface.instance = PosthogFlutterDesktop( + appDirectory: appDirectory.path, + appInfo: const DesktopAppInfo( + name: 'transport_test', + version: '1.2.3', + build: '42', + ), + timezone: 'Europe/Berlin', + ); + }); + + tearDown(() async { + await Posthog().close(); + PosthogFlutterPlatformInterface.instance = previousPlatform; + if (appDirectory.existsSync()) appDirectory.deleteSync(recursive: true); + }); + + PostHogConfig config({int flushAt = 1}) => PostHogConfig('phc_transport') + ..host = server.url + ..flushAt = flushAt + ..flushInterval = const Duration(hours: 1) + ..captureApplicationLifecycleEvents = false; + + test('facade sends a gzipped batch with envelope and event metadata', + () async { + await Posthog().setup(config(flushAt: 2)..preloadFeatureFlags = false); + + await Posthog().capture( + eventName: 'first event', + properties: {'source': 'desktop'}, + ); + await Posthog().capture(eventName: 'second event'); + await server.waitForEvent('second event'); + + final request = server.batchRequests.single; + expect(request.headers['content-encoding'], 'gzip'); + expect(request.headers['content-type'], 'application/json'); + expect(request.body['api_key'], 'phc_transport'); + expect(DateTime.tryParse(request.body['sent_at']! as String), isNotNull); + expect(request.eventNames, ['first event', 'second event']); + + for (final event in request.events) { + expect(event['uuid'], isA()); + expect(DateTime.tryParse(event['timestamp']! as String), isNotNull); + expect(event['distinct_id'], isA()); + final properties = Map.from(event['properties']! as Map); + expect(properties[r'$lib'], postHogFlutterSdkName); + expect(properties[r'$device_type'], 'Desktop'); + expect(properties[r'$app_version'], '1.2.3'); + expect(properties[r'$app_build'], 42); + expect(properties[r'$timezone'], 'Europe/Berlin'); + } + }); + + test('facade sends flag context and blocks capture while opted out', + () async { + server.respond = (request) => request.isFlags + ? PostHogResponse.json({'flags': {}}) + : const PostHogResponse(HttpStatus.ok); + await Posthog().setup(config()); + + final flags = await server.waitForFlagsRequest(0); + final personProperties = + Map.from(flags['person_properties']! as Map); + expect(flags['token'], 'phc_transport'); + expect(personProperties[r'$lib'], postHogFlutterSdkName); + expect(personProperties[r'$lib_version'], postHogFlutterVersion); + expect(personProperties[r'$device_type'], 'Desktop'); + expect(personProperties[r'$app_version'], '1.2.3'); + + await Posthog().disable(); + expect(await Posthog().isOptOut(), isTrue); + await Posthog().capture(eventName: 'blocked event'); + await Posthog().enable(); + await Posthog().capture(eventName: 'allowed event'); + await server.waitForEvent('allowed event'); + + expect(server.eventNames, isNot(contains('blocked event'))); + expect(server.eventNames, contains('allowed event')); + }); +} diff --git a/posthog_flutter/test/posthog_flutter_dart_test.dart b/posthog_flutter/test/posthog_flutter_dart_test.dart new file mode 100644 index 00000000..bddbbade --- /dev/null +++ b/posthog_flutter/test/posthog_flutter_dart_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter_dart.dart'; +import 'package:posthog_flutter/src/posthog_flutter_desktop.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; + +import 'posthog_flutter_platform_interface_fake.dart'; + +void main() { + test('registerWith installs the Windows and Linux implementation', () { + // Seed a known instance first: reading the uninitialized default would + // construct the method-channel implementation, which needs a Flutter + // binding. + final previous = PosthogFlutterPlatformFake(); + PosthogFlutterPlatformInterface.instance = previous; + addTearDown(() => PosthogFlutterPlatformInterface.instance = previous); + + PosthogFlutterDart.registerWith(); + + expect( + PosthogFlutterPlatformInterface.instance, + isA(), + ); + }); +} diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart new file mode 100644 index 00000000..11ee3575 --- /dev/null +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -0,0 +1,1181 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; +import 'package:posthog_flutter/src/posthog_config.dart'; +import 'package:posthog_flutter/src/posthog_desktop_app_info.dart'; +import 'package:posthog_flutter/src/posthog_flutter_desktop.dart'; +import 'package:posthog_flutter/src/posthog_flutter_version.dart'; + +import 'posthog_api_fake.dart'; + +// No TestWidgetsFlutterBinding here: the test binding replaces dart:io's +// HttpClient with a stub that answers 400 to everything, and these tests +// exercise the real HTTP path against a local server. +void main() { + Directory createAppDirectory() { + final dir = Directory.systemTemp.createTempSync('posthog_flutter_desktop'); + addTearDown(() => dir.deleteSync(recursive: true)); + return dir; + } + + /// A platform for an app that keeps its state in [appDirectory], a new + /// temporary directory by default, whose build recorded [appInfo] and + /// that runs in [timezone]. + PosthogFlutterDesktop createPlatform({ + Directory? appDirectory, + DesktopAppInfo appInfo = const DesktopAppInfo(), + String? timezone, + }) { + final platform = PosthogFlutterDesktop( + appDirectory: (appDirectory ?? createAppDirectory()).path, + appInfo: appInfo, + timezone: timezone, + ); + addTearDown(platform.close); + return platform; + } + + PostHogConfig configFor( + LocalPostHogServer server, { + String projectToken = 'test_project_token', + }) { + return PostHogConfig(projectToken) + ..host = server.url + ..flushAt = 1; + } + + Future setUpPlatform(PostHogConfig config) async { + final platform = createPlatform(); + await platform.setup(config); + return platform; + } + + group('PosthogFlutterDesktop before setup', () { + late PosthogFlutterDesktop platform; + + setUp(() { + platform = createPlatform(); + }); + + final callsByName = Function(PosthogFlutterDesktop)>{ + 'capture': (platform) => platform.capture(eventName: 'event'), + 'screen': (platform) => platform.screen(screenName: 'Home'), + 'identify': (platform) => platform.identify(userId: 'user-1'), + 'group': (platform) => + platform.group(groupType: 'company', groupKey: 'acme'), + 'alias': (platform) => platform.alias(alias: 'other-user'), + 'flush': (platform) => platform.flush(), + 'reset': (platform) => platform.reset(), + 'close': (platform) => platform.close(), + }; + + for (final entry in callsByName.entries) { + test('${entry.key} is ignored with a debug warning', () async { + final lines = await _printsOf(() => entry.value(platform)); + + expect(lines, [contains('${entry.key} ignored')]); + }); + } + + final nullReadsByName = + Function(PosthogFlutterDesktop)>{ + 'getFeatureFlag': (platform) => platform.getFeatureFlag(key: 'flag'), + 'getFeatureFlagPayload': (platform) => + platform.getFeatureFlagPayload(key: 'flag'), + 'getFeatureFlagResult': (platform) => + platform.getFeatureFlagResult(key: 'flag'), + 'getSessionId': (platform) => platform.getSessionId(), + }; + + for (final entry in nullReadsByName.entries) { + test('${entry.key} returns null', () async { + expect(await entry.value(platform), isNull); + }); + } + + test('getDistinctId returns an empty string', () async { + expect(await platform.getDistinctId(), ''); + }); + + test('isFeatureEnabled returns false', () async { + expect(await platform.isFeatureEnabled('flag'), isFalse); + }); + + test('isOptOut reports opted out', () async { + expect(await platform.isOptOut(), isTrue); + }); + }); + + group('PosthogFlutterDesktop.capture', () { + test('delivers the event with properties and person properties', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.capture( + eventName: 'purchase completed', + properties: {'plan': 'pro'}, + userProperties: {'name': 'Max'}, + userPropertiesSetOnce: {'signup_channel': 'organic'}, + ); + + final event = await server.waitForEvent('purchase completed'); + final properties = _propertiesOf(event); + expect(properties['plan'], 'pro'); + expect(properties[r'$set'], {'name': 'Max'}); + expect(properties[r'$set_once'], {'signup_channel': 'organic'}); + }); + + test('drops an event without a name, also one a callback renames to none', + () async { + final server = await LocalPostHogServer.start(); + final seenEventNames = []; + final config = configFor(server) + ..beforeSend = [ + (event) { + seenEventNames.add(event.event); + if (event.event == 'renamed away') event.event = ''; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + final lines = await _printsOf(() async { + await platform.capture(eventName: ''); + await platform.capture(eventName: 'renamed away'); + }); + await platform.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + expect(server.eventNames, ['sentinel event']); + expect(seenEventNames, ['renamed away', 'sentinel event'], + reason: 'an event without a name is dropped before the callbacks'); + expect(lines, [ + contains('empty event name'), + contains('empty event name'), + ]); + }); + + test(r'merges a legacy $set in properties, explicit keys winning', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.capture( + eventName: 'legacy merge', + properties: { + r'$set': {'plan': 'legacy', 'team': 'core'}, + }, + userProperties: {'plan': 'pro'}, + ); + + final event = await server.waitForEvent('legacy merge'); + expect(_propertiesOf(event)[r'$set'], {'plan': 'pro', 'team': 'core'}); + }); + + test(r'reports the platform language as $locale', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.capture(eventName: 'localized event'); + + final event = await server.waitForEvent('localized event'); + expect( + _propertiesOf(event)[r'$locale'], + PlatformDispatcher.instance.locale.languageCode, + ); + }); + + test(r'reports the time zone as $timezone and to /flags', () async { + final server = await LocalPostHogServer.start(); + final platform = createPlatform(timezone: 'Europe/Berlin'); + await platform.setup(configFor(server)); + + await platform.capture(eventName: 'zoned event'); + + final event = await server.waitForEvent('zoned event'); + expect(_propertiesOf(event)[r'$timezone'], 'Europe/Berlin'); + final flags = await server.waitForFlagsRequest(0); + expect(flags['timezone'], 'Europe/Berlin'); + }); + + test('leaves the time zone out when it is unknown', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.capture(eventName: 'unzoned event'); + + final event = await server.waitForEvent('unzoned event'); + expect(_propertiesOf(event), isNot(contains(r'$timezone'))); + final flags = await server.waitForFlagsRequest(0); + expect(flags, isNot(contains('timezone'))); + }); + + test(r'reports $recording_status disabled on every event', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.identify(userId: 'user-1'); + await platform.capture( + eventName: 'replay event', + properties: {r'$recording_status': 'active'}, + ); + + final identify = await server.waitForEvent(r'$identify'); + expect(_propertiesOf(identify)[r'$recording_status'], 'disabled'); + final captured = await server.waitForEvent('replay event'); + expect(_propertiesOf(captured)[r'$recording_status'], 'disabled'); + }); + }); + + group('PosthogFlutterDesktop.screen', () { + test(r'delivers $screen with the screen name property', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.screen( + screenName: 'Home Screen', + properties: {'tab': 'primary'}, + ); + + final event = await server.waitForEvent(r'$screen'); + final properties = _propertiesOf(event); + expect(properties[r'$screen_name'], 'Home Screen'); + expect(properties['tab'], 'primary'); + }); + + test(r'the screen name wins over a $screen_name property', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.screen( + screenName: 'Checkout', + properties: {r'$screen_name': 'Cart'}, + ); + await platform.capture(eventName: 'after screen'); + + final screen = await server.waitForEvent(r'$screen'); + expect(_propertiesOf(screen)[r'$screen_name'], 'Checkout'); + final after = await server.waitForEvent('after screen'); + expect(_propertiesOf(after)[r'$screen_name'], 'Checkout'); + }); + + test('does nothing while opted out', () async { + final server = await LocalPostHogServer.start(); + final seenEventNames = []; + final config = configFor(server) + ..beforeSend = [ + (event) { + seenEventNames.add(event.event); + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.disable(); + await platform.screen(screenName: 'Hidden'); + await platform.enable(); + await platform.capture(eventName: 'sentinel event'); + + final event = await server.waitForEvent('sentinel event'); + expect(server.eventNames, ['sentinel event']); + expect(seenEventNames, ['sentinel event'], + reason: 'no callback runs for a screen while opted out'); + expect(_propertiesOf(event), isNot(contains(r'$screen_name')), + reason: 'the screen did not become the current one'); + }); + + test('keeps the screen name when a callback rebuilds the properties', + () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [ + (event) { + event.properties = {'scrubbed': true}; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.screen(screenName: 'Home Screen'); + + final event = await server.waitForEvent(r'$screen'); + final properties = _propertiesOf(event); + expect(properties[r'$screen_name'], 'Home Screen', + reason: 'the screen name is re-added after the callbacks, so one ' + 'dropping the key must not lose it'); + expect(properties['scrubbed'], isTrue); + }); + + test('later events carry the last screen name', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.screen(screenName: 'Checkout'); + await platform.capture(eventName: 'after screen'); + await platform.identify(userId: 'user-1'); + + final captured = await server.waitForEvent('after screen'); + expect(_propertiesOf(captured)[r'$screen_name'], 'Checkout'); + final identify = await server.waitForEvent(r'$identify'); + expect(_propertiesOf(identify)[r'$screen_name'], 'Checkout'); + }); + + test('an explicit screen name wins over the last screen', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.screen(screenName: 'Checkout'); + await platform.capture( + eventName: 'explicit screen', + properties: {r'$screen_name': 'Cart'}, + ); + + final event = await server.waitForEvent('explicit screen'); + expect(_propertiesOf(event)[r'$screen_name'], 'Cart'); + }); + + test('reset forgets the last screen', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.screen(screenName: 'Checkout'); + await platform.reset(); + await platform.capture(eventName: 'after reset'); + + final event = await server.waitForEvent('after reset'); + expect(_propertiesOf(event), isNot(contains(r'$screen_name'))); + }); + + test('drops a screen with an empty name', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.screen(screenName: 'Checkout'); + await platform.screen(screenName: ''); + await platform.capture(eventName: 'sentinel event'); + + final event = await server.waitForEvent('sentinel event'); + expect( + server.eventNames.where((name) => name == r'$screen'), hasLength(1)); + expect(_propertiesOf(event)[r'$screen_name'], 'Checkout'); + }); + }); + + group('beforeSend hook', () { + test('dropping an event keeps it off the wire', () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [ + (event) => event.event == 'dropped event' ? null : event, + ]; + final platform = await setUpPlatform(config); + + await platform.capture(eventName: 'dropped event'); + await platform.capture(eventName: 'sentinel event'); + + // Events are delivered in capture order, so once the sentinel arrived + // the dropped event can no longer be in flight. + await server.waitForEvent('sentinel event'); + expect(server.eventNames, isNot(contains('dropped event'))); + }); + + test('modifications made by a callback are delivered', () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [ + (event) { + event.event = 'renamed event'; + event.properties = {...?event.properties, 'amended': true}; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.capture( + eventName: 'original event', + properties: {'plan': 'pro'}, + ); + + final event = await server.waitForEvent('renamed event'); + final properties = _propertiesOf(event); + expect(properties['plan'], 'pro'); + expect(properties['amended'], isTrue); + expect(server.eventNames, isNot(contains('original event'))); + }); + + test('a callback can rewrite the person properties', () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..beforeSend = [ + (event) => event..userProperties = {'plan': 'enterprise'}, + ]; + final platform = await setUpPlatform(config); + + await platform.capture( + eventName: 'upgraded', + userProperties: {'plan': 'pro'}, + ); + + final event = await server.waitForEvent('upgraded'); + expect(_propertiesOf(event)[r'$set'], {'plan': 'enterprise'}); + }); + + test('a throwing callback drops the event and stops the chain', () async { + final server = await LocalPostHogServer.start(); + final callOrder = []; + final config = configFor(server) + ..beforeSend = [ + (event) { + if (event.event == 'failing event') callOrder.add('transform'); + event.properties = {...?event.properties, 'scrubbed': true}; + return event; + }, + (event) { + if (event.event != 'failing event') return event; + callOrder.add('throw'); + throw StateError('broken callback'); + }, + (event) { + if (event.event == 'failing event') callOrder.add('after throw'); + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await expectLater( + platform.capture(eventName: 'failing event'), + completes, + ); + await platform.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + expect(server.eventNames, isNot(contains('failing event'))); + expect(callOrder, ['transform', 'throw']); + }); + + test('callbacks see user-provided properties only', () async { + final server = await LocalPostHogServer.start(); + Map? seenProperties; + final config = configFor(server) + ..beforeSend = [ + (event) { + seenProperties = event.properties; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.capture( + eventName: 'context event', + properties: {'plan': 'pro'}, + ); + + final event = await server.waitForEvent('context event'); + // The whole map: no $session_id, $lib or other SDK enrichment is + // visible to the hook... + expect(seenProperties, {'plan': 'pro'}); + // ...while the delivered event is still enriched after the hook ran. + final properties = _propertiesOf(event); + expect(properties, containsPair(r'$lib', 'posthog-flutter')); + expect(properties.keys, contains(r'$session_id')); + }); + + test('SDK-internal events bypass the callbacks', () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + final flagsLoaded = Completer(); + final seenEventNames = []; + final config = configFor(server) + ..onFeatureFlags = () { + if (!flagsLoaded.isCompleted) flagsLoaded.complete(); + } + ..beforeSend = [ + (event) { + seenEventNames.add(event.event); + return event; + }, + ]; + final platform = await setUpPlatform(config); + await flagsLoaded.future; + + await platform.getFeatureFlagResult(key: 'variant-flag'); + await platform.capture(eventName: 'user event'); + + await server.waitForEvent(r'$feature_flag_called'); + await server.waitForEvent('user event'); + // Both events reached the wire, but only the user-initiated capture + // passed through the hook. + expect(seenEventNames, ['user event']); + }); + }); + + group('PosthogFlutterDesktop.captureException', () { + test(r'delivers $exception with a processed exception list', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + late Object error; + late StackTrace stackTrace; + try { + throw StateError('desktop failure'); + } catch (e, s) { + error = e; + stackTrace = s; + } + await platform.captureException(error: error, stackTrace: stackTrace); + + final event = await server.waitForEvent(r'$exception'); + final properties = _propertiesOf(event); + expect(properties[r'$exception_level'], 'error'); + + final exceptionList = properties[r'$exception_list']! as List; + final exception = Map.from(exceptionList.first as Map); + expect(exception['type'], 'StateError'); + expect(exception['value'], 'Bad state: desktop failure'); + + final stacktrace = + Map.from(exception['stacktrace']! as Map); + expect(stacktrace['frames'], isNotEmpty); + }); + }); + + group('Error autocapture', () { + test('reports PlatformDispatcher errors through the desktop client', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + final previousOnError = PlatformDispatcher.instance.onError; + addTearDown(() => PlatformDispatcher.instance.onError = previousOnError); + addTearDown(PostHogErrorTrackingAutoCaptureIntegration.uninstall); + + PostHogErrorTrackingAutoCaptureIntegration.install( + config: PostHogErrorTrackingConfig() + ..capturePlatformDispatcherErrors = true, + posthog: platform, + ); + PlatformDispatcher.instance.onError!( + StateError('uncaught'), + StackTrace.current, + ); + + final event = await server.waitForEvent(r'$exception'); + final exceptionList = _propertiesOf(event)[r'$exception_list']! as List; + final exception = Map.from(exceptionList.first as Map); + expect(exception['value'], 'Bad state: uncaught'); + expect((exception['mechanism']! as Map)['type'], 'PlatformDispatcher'); + }); + }); + + group('Exception steps', () { + List? stepMessagesOf(Map event) { + final steps = _propertiesOf(event)[r'$exception_steps'] as List?; + return steps?.map((step) => (step as Map)[r'$message']).toList(); + } + + List> exceptionsOn(LocalPostHogServer server) => + server.events + .where((event) => event['event'] == r'$exception') + .toList(); + + Object? errorOf(Map event) => + ((_propertiesOf(event)[r'$exception_list']! as List).first + as Map)['value']; + + test(r'attach to every $exception in the order recorded', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.addExceptionStep('first', properties: {'screen': 'cart'}); + await platform.addExceptionStep('second'); + await platform.captureException(error: StateError('one')); + await platform.addExceptionStep('third'); + await platform.captureException(error: StateError('two')); + await platform.capture(eventName: 'sentinel event'); + + final sentinel = await server.waitForEvent('sentinel event'); + final exceptions = exceptionsOn(server); + expect(stepMessagesOf(exceptions[0]), ['first', 'second']); + expect(stepMessagesOf(exceptions[1]), ['first', 'second', 'third']); + final firstStep = (_propertiesOf(exceptions[0])[r'$exception_steps']! + as List)[0] as Map; + expect(firstStep['screen'], 'cart'); + expect(firstStep[r'$timestamp'], isA()); + expect(stepMessagesOf(sentinel), isNull); + }); + + test('send their values as event properties send them', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform + .addExceptionStep('step', properties: {'ratio': double.nan}); + await platform.captureException( + error: StateError('not a number'), + properties: {'ratio': double.nan}, + ); + + final properties = + _propertiesOf(await server.waitForEvent(r'$exception')); + final step = (properties[r'$exception_steps']! as List).single as Map; + expect(properties['ratio'], 'NaN'); + expect(step['ratio'], 'NaN'); + }); + + test(r'attach to an $exception captured by name', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.addExceptionStep('recorded'); + await platform.capture( + eventName: r'$exception', + properties: { + r'$exception_list': [ + {'type': 'CustomError', 'value': 'captured by name'}, + ], + }, + ); + + final event = await server.waitForEvent(r'$exception'); + expect(stepMessagesOf(event), ['recorded']); + }); + + test(r'leave an $exception_steps set on the event unchanged', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.addExceptionStep('recorded'); + await platform.captureException( + error: StateError('custom steps'), + properties: { + r'$exception_steps': [ + {r'$message': 'custom'}, + ], + }, + ); + + final event = await server.waitForEvent(r'$exception'); + expect(stepMessagesOf(event), ['custom']); + }); + + test('are not attached while disabled', () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server) + ..errorTrackingConfig.exceptionSteps.enabled = false; + final platform = await setUpPlatform(config); + + await platform.addExceptionStep('ignored'); + await platform.captureException(error: StateError('no steps')); + + final event = await server.waitForEvent(r'$exception'); + expect(stepMessagesOf(event), isNull); + }); + + test('are not recorded while opted out', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.disable(); + await platform.addExceptionStep('while opted out'); + await platform.enable(); + await platform.captureException(error: StateError('opted in')); + + final event = await server.waitForEvent(r'$exception'); + expect(stepMessagesOf(event), isNull); + }); + + test('survive reset and are cleared by close', () async { + final server = await LocalPostHogServer.start(); + final config = configFor(server); + final platform = await setUpPlatform(config); + + await platform.addExceptionStep('before reset'); + await platform.reset(); + await platform.captureException(error: StateError('after reset')); + await platform.close(); + await platform.setup(config); + await platform.captureException(error: StateError('after close')); + await platform.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + // The next client may send the first one's exception again, since + // close() does not wait for the network, so they are told apart by + // their error rather than their position. + final stepsByError = { + for (final event in exceptionsOn(server)) + errorOf(event): stepMessagesOf(event), + }; + expect(stepsByError, { + 'Bad state: after reset': ['before reset'], + 'Bad state: after close': null, + }); + }); + }); + + group('Application lifecycle', () { + /// A launch of the app with [build], keeping its state in [appDirectory]. + PosthogFlutterDesktop launch( + Directory appDirectory, { + String build = '100', + }) => + createPlatform( + appDirectory: appDirectory, + appInfo: DesktopAppInfo(version: '1.0.0', build: build), + ); + + test('the first setup captures Application Installed', () async { + final server = await LocalPostHogServer.start(); + await launch(createAppDirectory()).setup(configFor(server)); + + final event = await server.waitForEvent('Application Installed'); + final properties = _propertiesOf(event); + expect(properties['version'], '1.0.0'); + expect(properties['build'], 100); + expect(properties[r'$app_version'], '1.0.0'); + expect(properties[r'$app_build'], 100); + }); + + test('a launch with a new build captures Application Updated', () async { + final server = await LocalPostHogServer.start(); + final appDirectory = createAppDirectory(); + final firstLaunch = launch(appDirectory); + await firstLaunch.setup(configFor(server)); + await server.waitForEvent('Application Installed'); + await firstLaunch.close(); + + await launch(appDirectory, build: '101').setup(configFor(server)); + + final event = await server.waitForEvent('Application Updated'); + expect(_propertiesOf(event)['previous_build'], 100); + expect(_propertiesOf(event)['build'], 101); + }); + + test('reset keeps the recorded version for the next launch', () async { + final server = await LocalPostHogServer.start(); + final appDirectory = createAppDirectory(); + final firstLaunch = launch(appDirectory); + await firstLaunch.setup(configFor(server)); + await server.waitForEvent('Application Installed'); + + // A logout, then the next launch of the same version. + await firstLaunch.reset(); + await firstLaunch.close(); + final nextLaunch = launch(appDirectory); + await nextLaunch.setup(configFor(server)); + await nextLaunch.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + final lifecycleEvents = server.events + .where((event) => '${event['event']}'.startsWith('Application ')); + // close() does not wait for the network, so the install can be sent + // again by the next launch; it keeps its uuid, which PostHog dedupes. + expect( + {for (final event in lifecycleEvents) event['event']}, + {'Application Installed'}, + ); + expect( + {for (final event in lifecycleEvents) event['uuid']}, hasLength(1)); + }); + + test('an app whose build recorded no version captures no install', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + await platform.capture(eventName: 'sentinel event'); + + final sentinel = await server.waitForEvent('sentinel event'); + expect(server.eventNames, ['sentinel event']); + expect(_propertiesOf(sentinel), isNot(contains(r'$app_version'))); + }); + + test('lifecycle events bypass the beforeSend callbacks', () async { + final server = await LocalPostHogServer.start(); + final seenEventNames = []; + final config = configFor(server) + ..beforeSend = [ + (event) { + seenEventNames.add(event.event); + return event; + }, + ]; + await launch(createAppDirectory()).setup(config); + + await server.waitForEvent('Application Installed'); + expect(seenEventNames, isEmpty); + }); + + test('an app opted out at setup reports its install on opt-in', () async { + final server = await LocalPostHogServer.start(); + final platform = launch(createAppDirectory()); + await platform.setup(configFor(server)..optOut = true); + + await platform.enable(); + + await server.waitForEvent('Application Installed'); + }); + }); + + group('Storage', () { + test('keeps each project in its own directory', () async { + final server = await LocalPostHogServer.start(); + final appDirectory = createAppDirectory(); + + Future runWithToken(String projectToken) async { + final platform = createPlatform(appDirectory: appDirectory); + await platform.setup(configFor(server, projectToken: projectToken)); + // The first read persists the generated anonymous id. + await platform.getDistinctId(); + await platform.close(); + } + + await runWithToken('token_one'); + await runWithToken('token_two'); + + final sep = Platform.pathSeparator; + expect( + Directory('${appDirectory.path}${sep}token_one').existsSync(), + isTrue, + ); + expect( + Directory('${appDirectory.path}${sep}token_two').existsSync(), + isTrue, + ); + }); + + test('the next launch keeps the identity and the opt-out', () async { + final server = await LocalPostHogServer.start(); + final appDirectory = createAppDirectory(); + final firstLaunch = createPlatform(appDirectory: appDirectory); + await firstLaunch.setup(configFor(server)); + final distinctId = await firstLaunch.getDistinctId(); + await firstLaunch.disable(); + await firstLaunch.close(); + + final nextLaunch = createPlatform(appDirectory: appDirectory); + await nextLaunch.setup(configFor(server)); + + expect(await nextLaunch.isOptOut(), isTrue); + expect(await nextLaunch.getDistinctId(), distinctId); + }); + }); + + group('PosthogFlutterDesktop.setup', () { + test('calls made right after an unawaited setup reach the client', + () async { + final server = await LocalPostHogServer.start(); + final platform = createPlatform(); + + final setup = platform.setup(configFor(server)); + final identify = platform.identify(userId: 'early-user'); + await Future.wait([setup, identify]); + + expect(await platform.getDistinctId(), 'early-user'); + final event = await server.waitForEvent(r'$identify'); + expect(event['distinct_id'], 'early-user'); + }); + + test('disable right after an unawaited setup keeps the user opted out', + () async { + final server = await LocalPostHogServer.start(); + final platform = createPlatform(); + + final setup = platform.setup(configFor(server)); + final disable = platform.disable(); + await Future.wait([setup, disable]); + + expect(await platform.isOptOut(), isTrue); + }); + + test('a repeated setup keeps the running client', () async { + final server = await LocalPostHogServer.start(); + final otherServer = await LocalPostHogServer.start(); + final platform = await setUpPlatform( + configFor(server), + ); + + await platform.setup( + configFor(otherServer, projectToken: 'other'), + ); + await platform.capture(eventName: 'after repeated setup'); + + await server.waitForEvent('after repeated setup'); + expect(otherServer.events, isEmpty); + expect(otherServer.flagsRequests, isEmpty); + }); + + test('a repeated setup applies its beforeSend callbacks', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform( + configFor(server) + ..beforeSend = [ + (event) => event..properties = {'hook': 'first'}, + ], + ); + + await platform.setup( + configFor(server) + ..beforeSend = [ + (event) => event..properties = {'hook': 'second'}, + ], + ); + await platform.capture(eventName: 'hooked event'); + + final event = await server.waitForEvent('hooked event'); + expect(_propertiesOf(event)['hook'], 'second'); + }); + + test('setup right after an unawaited close starts the next client', + () async { + final server = await LocalPostHogServer.start(); + final platform = createPlatform(); + + await platform.setup(configFor(server)..flushAt = 20); + await platform.capture(eventName: 'first client event'); + + // The closed client leaves its queued event on disk, where the next + // client picks it up. + final close = platform.close(); + final setup = platform.setup(configFor(server)); + final capture = platform.capture(eventName: 'second client event'); + await Future.wait([close, setup, capture]); + + await server.waitForEvent('second client event'); + expect(server.eventNames, ['first client event', 'second client event']); + }); + + test('an event whose callbacks finish after close reaches the next client', + () async { + final server = await LocalPostHogServer.start(); + final release = Completer(); + final config = configFor(server) + ..beforeSend = [ + (event) async { + if (event.event == 'slow event') await release.future; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + final slowCapture = platform.capture(eventName: 'slow event'); + await platform.close(); + await platform.setup(config); + release.complete(); + await slowCapture; + await platform.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + expect(server.eventNames, ['slow event', 'sentinel event']); + }); + + test('isOptOut reports opted out after close', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform( + configFor(server), + ); + expect(await platform.isOptOut(), isFalse); + + await platform.close(); + + expect(await platform.isOptOut(), isTrue); + }); + + test('calls after close are ignored with a debug warning', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + await platform.close(); + + final lines = await _printsOf(() async { + await platform.capture(eventName: 'after close'); + await platform.identify(userId: 'user-1'); + }); + + expect( + lines, [contains('capture ignored'), contains('identify ignored')]); + expect(server.events, isEmpty); + }); + }); + + group('Bootstrap', () { + test('seeds the distinct id and feature flags before any /flags response', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform( + configFor(server) + ..preloadFeatureFlags = false + ..bootstrap = const PostHogBootstrapConfig( + distinctId: 'bootstrapped-anon', + featureFlags: {'beta': true, 'checkout': 'variant-b'}, + featureFlagPayloads: { + 'beta': {'color': 'blue'}, + }, + ), + ); + + expect(await platform.getDistinctId(), 'bootstrapped-anon'); + expect(await platform.getFeatureFlag(key: 'beta'), isTrue); + expect(await platform.getFeatureFlag(key: 'checkout'), 'variant-b'); + expect( + await platform.getFeatureFlagPayload(key: 'beta'), + {'color': 'blue'}, + ); + expect(server.flagsRequests, isEmpty); + }); + + test('an identified bootstrap sends events as the identified user', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform( + configFor(server) + ..preloadFeatureFlags = false + ..bootstrap = const PostHogBootstrapConfig( + distinctId: 'known-user', + isIdentifiedId: true, + ), + ); + + await platform.capture(eventName: 'after bootstrap'); + + final event = await server.waitForEvent('after bootstrap'); + expect(event['distinct_id'], 'known-user'); + expect(_propertiesOf(event)[r'$is_identified'], isTrue); + }); + }); + + group('Feature flag evaluation', () { + test('flags requests carry default person properties', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + + final preload = await server.waitForFlagsRequest(0); + final personProperties = + Map.from(preload['person_properties']! as Map); + expect(personProperties[r'$device_type'], 'Desktop'); + expect(personProperties[r'$os_name'], isNotNull); + expect(personProperties[r'$lib'], 'posthog-flutter'); + expect(personProperties[r'$lib_version'], postHogFlutterVersion); + + await platform.setPersonPropertiesForFlags({r'$os_name': 'Custom OS'}); + await platform.reloadFeatureFlags(); + + final reload = await server.waitForFlagsRequest(1); + final overridden = + Map.from(reload['person_properties']! as Map); + expect(overridden[r'$os_name'], 'Custom OS'); + expect(overridden[r'$device_type'], 'Desktop'); + }); + + test('setPersonProperties updates flag properties without a reload', + () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform( + configFor(server)..preloadFeatureFlags = false, + ); + + await platform.setPersonProperties( + userPropertiesToSet: {'plan': 'pro'}, + ); + await platform.reloadFeatureFlags(); + + // Only the explicit reload reached /flags, already carrying the + // property. + expect(server.flagsRequests, hasLength(1)); + expect( + Map.from( + server.flagsRequests.single.body['person_properties']! as Map, + ), + containsPair('plan', 'pro'), + ); + }); + + test(r'getFeatureFlagPayload does not capture $feature_flag_called', + () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + final flagsLoaded = Completer(); + final config = configFor(server) + ..onFeatureFlags = () { + if (!flagsLoaded.isCompleted) flagsLoaded.complete(); + }; + final platform = await setUpPlatform(config); + await flagsLoaded.future; + + final payload = await platform.getFeatureFlagPayload( + key: 'variant-flag', + ); + expect(payload, {'color': 'blue'}); + + await platform.capture(eventName: 'sentinel event'); + await server.waitForEvent('sentinel event'); + expect(server.eventNames, isNot(contains(r'$feature_flag_called'))); + + // The default evaluation path still reports the call. + final result = await platform.getFeatureFlagResult(key: 'variant-flag'); + expect(result?.variant, 'test-variant'); + final called = await server.waitForEvent(r'$feature_flag_called'); + expect(_propertiesOf(called)[r'$feature_flag'], 'variant-flag'); + }); + + test('onFeatureFlags reports the flags cached by the previous launch', + () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + final appDirectory = createAppDirectory(); + final previousLaunch = createPlatform(appDirectory: appDirectory); + await previousLaunch + .setup(configFor(server)..preloadFeatureFlags = false); + await previousLaunch.reloadFeatureFlags(); + await previousLaunch.close(); + + final platform = createPlatform(appDirectory: appDirectory); + final payloads = >[]; + final announced = Completer(); + await platform.setup(configFor(server) + ..preloadFeatureFlags = false + ..onFeatureFlags = () { + payloads.add(platform.getFeatureFlagPayload(key: 'variant-flag')); + if (!announced.isCompleted) announced.complete(); + }); + await announced.future; + + expect(await Future.wait(payloads), [ + {'color': 'blue'}, + ]); + expect(server.flagsRequests, hasLength(1), + reason: 'the cached flags need no request of their own'); + }); + }); +} + +Map _propertiesOf(Map event) => + Map.from(event['properties']! as Map); + +/// Runs [body] and returns the lines it printed. +Future> _printsOf(Future Function() body) async { + final lines = []; + await runZoned( + body, + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) => lines.add(line), + ), + ); + return lines; +} + +/// A /flags/ v2 response with one multivariate flag carrying a payload. +Map _variantFlagResponse() => { + 'flags': { + 'variant-flag': { + 'key': 'variant-flag', + 'enabled': true, + 'variant': 'test-variant', + 'metadata': { + 'id': 1, + 'version': 2, + 'payload': '{"color":"blue"}', + }, + }, + }, + }; From 7a1510f7cd3788a40c79266d0772af5d98c83405 Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Tue, 29 Sep 2026 14:07:21 +0300 Subject: [PATCH 02/22] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20recover=20desktop?= =?UTF-8?q?=20delivery=20after=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/src/core/posthog_core_stateless.dart | 75 ++++++++- .../test/core/queue_flush_test.dart | 30 ++++ posthog_flutter/test/core/requests_test.dart | 149 ++++++++++++++++++ posthog_flutter/test/posthog_api_fake.dart | 18 ++- 4 files changed, 263 insertions(+), 9 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index fac6c431..03cd047d 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -498,7 +498,10 @@ abstract class PostHogCoreStateless { // Peeked again after every request, so events queued in the meantime // go out with the same flush. final batch = queue.peek(_maxBatchSize); - if (batch.isEmpty) break; + if (batch.isEmpty) { + if (queue.length > 0) _scheduleFlush(); + break; + } final batchMessages = [for (final queued in batch) queued.event]; final data = { @@ -563,8 +566,15 @@ abstract class PostHogCoreStateless { return retriable( () async { final ({int status, String body, Duration? retryAfter}) response; + final attempt = _FetchAttempt(); try { - response = await _fetch(url, body).timeout(_requestTimeout); + response = await _fetch(url, body, attempt).timeout( + _requestTimeout, + onTimeout: () async { + await attempt.cancel(); + throw attempt.timeout; + }, + ); } catch (e) { throw PostHogFetchNetworkError(e); } @@ -607,8 +617,9 @@ abstract class PostHogCoreStateless { } Future<({int status, String body, Duration? retryAfter})> _fetch( - String url, String body) async { + String url, String body, _FetchAttempt attempt) async { final request = await _httpClient.postUrl(Uri.parse(url)); + attempt.attachRequest(request); // A redirect is reported rather than followed, so a redirected batch is // not taken for delivered. request.followRedirects = false; @@ -631,9 +642,7 @@ abstract class PostHogCoreStateless { request.add(payload); final response = await request.close(); - final responseBody = await response - .transform(const Utf8Decoder(allowMalformed: true)) - .join(); + final responseBody = await attempt.readBody(response); return ( status: response.statusCode, body: responseBody, @@ -654,3 +663,57 @@ abstract class PostHogCoreStateless { storage.close(); } } + +class _FetchAttempt { + final timeout = TimeoutException('PostHog request timed out'); + + HttpClientRequest? _request; + StreamSubscription? _responseSubscription; + Completer? _responseBody; + bool _cancelled = false; + + void attachRequest(HttpClientRequest request) { + _request = request; + if (_cancelled) { + request.abort(timeout); + throw timeout; + } + } + + Future readBody(HttpClientResponse response) { + final contents = StringBuffer(); + final body = Completer(); + late final StreamSubscription subscription; + subscription = + response.transform(const Utf8Decoder(allowMalformed: true)).listen( + contents.write, + onError: (Object error, StackTrace stackTrace) { + if (!body.isCompleted) body.completeError(error, stackTrace); + }, + onDone: () { + if (!body.isCompleted) body.complete(contents.toString()); + }, + cancelOnError: true, + ); + _responseSubscription = subscription; + _responseBody = body; + return body.future.whenComplete(() { + if (identical(_responseSubscription, subscription)) { + _responseSubscription = null; + _responseBody = null; + } + }); + } + + Future cancel() async { + _cancelled = true; + final responseSubscription = _responseSubscription; + if (responseSubscription == null) { + _request?.abort(timeout); + } else { + await responseSubscription.cancel(); + } + final body = _responseBody; + if (body != null && !body.isCompleted) body.completeError(timeout); + } +} diff --git a/posthog_flutter/test/core/queue_flush_test.dart b/posthog_flutter/test/core/queue_flush_test.dart index 53db1d7a..21b55a5e 100644 --- a/posthog_flutter/test/core/queue_flush_test.dart +++ b/posthog_flutter/test/core/queue_flush_test.dart @@ -644,6 +644,36 @@ void main() { expect(server.batchRequests.single.eventNames, ['sign_in']); expect(getQueue(storage), isEmpty); }); + + test('retries a queued event after its file becomes readable again', + () async { + final dir = tempDirectory(); + testClient(server, + config: testConfig(flushInterval: Duration.zero), + storage: FileStorage(dir.path)) + ..capture('persisted') + ..close(); + final queueFile = Directory('${dir.path}/posthog_queue') + .listSync() + .whereType() + .single; + chmod('000', queueFile.path); + addTearDown(() => chmod('644', queueFile.path)); + + testClient(server, + config: testConfig(flushInterval: const Duration(milliseconds: 20)), + storage: FileStorage(dir.path)); + await Future.delayed(const Duration(milliseconds: 50)); + expect(server.batchRequests, isEmpty); + + chmod('644', queueFile.path); + await expectLater( + server.waitForEvent('persisted').timeout( + const Duration(seconds: 1), + ), + completes, + ); + }, skip: chmodSkip); }); group('PostHogCore.close', () { diff --git a/posthog_flutter/test/core/requests_test.dart b/posthog_flutter/test/core/requests_test.dart index ff259b26..88e554ed 100644 --- a/posthog_flutter/test/core/requests_test.dart +++ b/posthog_flutter/test/core/requests_test.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:io'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/core/posthog_core_stateless.dart'; import 'package:posthog_flutter/src/posthog_flutter_version.dart'; @@ -84,5 +86,152 @@ void main() { storage, r'$feature_flag_called')[r'$feature_flag_error'], 'connection_error'); }); + + test('aborts a request that becomes available after its timeout', () { + fakeAsync((async) { + final opened = Completer(); + final http = _ControlledHttpClient((_) => opened.future); + late final client = HttpOverrides.runZoned( + () => testClient(server), + createHttpClient: (_) => http, + ); + client.capture('evt'); + client.flush().catchError((Object _) {}); + async.flushMicrotasks(); + + async.elapse(const Duration(seconds: 10)); + final request = _ControlledRequest(); + opened.complete(request); + async.flushMicrotasks(); + + expect(request.abortCalls, 1); + expect(request.closeCalls, 0, + reason: 'a request opened after its deadline must not be sent'); + client.close(); + async.elapse(const Duration(seconds: 3)); + }); + }); + + test('cancels response body reading before retrying a timeout', () { + fakeAsync((async) { + var bodyCancellationStarted = false; + final bodyCancellationDone = Completer(); + final body = StreamController>( + onCancel: () { + bodyCancellationStarted = true; + return bodyCancellationDone.future; + }, + ); + final request = _ControlledRequest( + response: _ControlledResponse(body.stream), + ); + var openCalls = 0; + final http = _ControlledHttpClient((_) async { + openCalls++; + if (openCalls == 1) return request; + expect(bodyCancellationDone.isCompleted, isTrue, + reason: 'retry must wait until the old response is cancelled'); + return _ControlledRequest(); + }); + late final client = HttpOverrides.runZoned( + () => testClient(server), + createHttpClient: (_) => http, + ); + client.capture('evt'); + client.flush().catchError((Object _) {}); + async.flushMicrotasks(); + + async.elapse(const Duration(seconds: 10)); + async.flushMicrotasks(); + + expect(bodyCancellationStarted, isTrue); + async.elapse(const Duration(seconds: 4)); + async.flushMicrotasks(); + expect(openCalls, 1, + reason: 'retry must not overlap response cancellation'); + + bodyCancellationDone.complete(); + async.flushMicrotasks(); + async.elapse(const Duration(seconds: 4)); + async.flushMicrotasks(); + expect(openCalls, 2); + client.close(); + }); + }); }); } + +class _ControlledHttpClient implements HttpClient { + _ControlledHttpClient(this.openRequest); + + final Future Function(Uri uri) openRequest; + + @override + Future postUrl(Uri url) => openRequest(url); + + @override + void close({bool force = false}) {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ControlledRequest implements HttpClientRequest { + _ControlledRequest({HttpClientResponse? response}) + : _response = response ?? _ControlledResponse(Stream.value([])); + + final HttpClientResponse _response; + int abortCalls = 0; + int closeCalls = 0; + + @override + final headers = _ControlledHeaders(); + + @override + bool followRedirects = true; + + @override + int contentLength = -1; + + @override + void add(List data) {} + + @override + void abort([Object? exception, StackTrace? stackTrace]) { + abortCalls++; + } + + @override + Future close() async { + closeCalls++; + return _response; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ControlledResponse extends StreamView> + implements HttpClientResponse { + _ControlledResponse(super.stream); + + @override + int get statusCode => HttpStatus.ok; + + @override + final headers = _ControlledHeaders(); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _ControlledHeaders implements HttpHeaders { + @override + void set(String name, Object value, {bool preserveHeaderCase = false}) {} + + @override + String? value(String name) => null; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/posthog_flutter/test/posthog_api_fake.dart b/posthog_flutter/test/posthog_api_fake.dart index e4e3ca31..eb246b0b 100644 --- a/posthog_flutter/test/posthog_api_fake.dart +++ b/posthog_flutter/test/posthog_api_fake.dart @@ -250,6 +250,7 @@ class _InProcessRequest implements HttpClientRequest { final PostHogApiFake _api; final _body = []; + final _aborted = Completer(); @override final Uri uri; @@ -266,11 +267,22 @@ class _InProcessRequest implements HttpClientRequest { @override void add(List data) => _body.addAll(data); + @override + void abort([Object? exception, StackTrace? stackTrace]) { + if (!_aborted.isCompleted) { + _aborted.completeError( + exception ?? const HttpException('Request aborted'), + stackTrace, + ); + } + } + @override Future close() async { - final response = await _api._answer( - PostHogRequest._decode(uri.path, headers.values, _body), - ); + final response = await Future.any([ + _api._answer(PostHogRequest._decode(uri.path, headers.values, _body)), + _aborted.future, + ]); if (response.isDropped) { throw const HttpException( 'Connection closed before full header was received', From 43940bad2f13d9177d9c0706dc15e18a8f1bc7c8 Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Tue, 29 Sep 2026 14:07:21 +0300 Subject: [PATCH 03/22] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20isolate=20temporar?= =?UTF-8?q?y=20desktop=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/desktop-dart-sdk.md | 2 +- README.md | 7 ++++ posthog_flutter/lib/src/core/PORTING_NOTES.md | 4 +++ .../lib/src/posthog_desktop_lifecycle.dart | 6 ++-- .../lib/src/posthog_desktop_storage.dart | 11 ++++--- .../test/posthog_desktop_storage_test.dart | 32 +++++++++++++++---- 6 files changed, 47 insertions(+), 15 deletions(-) diff --git a/.changeset/desktop-dart-sdk.md b/.changeset/desktop-dart-sdk.md index 92be0812..74b4b0e8 100644 --- a/.changeset/desktop-dart-sdk.md +++ b/.changeset/desktop-dart-sdk.md @@ -2,4 +2,4 @@ "posthog_flutter": minor --- -Add Windows and Linux support, set up with the same `Posthog().setup(config)` as the other platforms. Event and screen capture, exception capture (including autocapture of Flutter and Dart errors, and exception steps), identify, alias, groups, super properties, feature flags, bootstrap, opt-out and application lifecycle events work on both. The app name, version and build number come from the pubspec as built by Flutter, and the SDK keeps its state in the user's application data directory (`%APPDATA%\posthog` on Windows, `$XDG_DATA_HOME/posthog` or `~/.local/share/posthog` on Linux). Events include `$timezone` when the SDK can determine a local IANA time zone; on Windows, this uses the system ICU data on a best-effort basis. Session replay, surveys, logs, push notifications and native crash capture are not supported on Windows and Linux. +Add Windows and Linux support, set up with the same `Posthog().setup(config)` as the other platforms. Event and screen capture, exception capture (including autocapture of Flutter and Dart errors, and exception steps), identify, alias, groups, super properties, feature flags, bootstrap, opt-out and application lifecycle events work on both. The app name, version and build number come from the pubspec as built by Flutter, and the SDK keeps its state in the user's application data directory (`%APPDATA%\posthog` on Windows, `$XDG_DATA_HOME/posthog` or `~/.local/share/posthog` on Linux). If no application data directory is available, state uses a private temporary directory for that registration and is not reused on the next launch. Events include `$timezone` when the SDK can determine a local IANA time zone; on Windows, this uses the system ICU data on a best-effort basis. Session replay, surveys, logs, push notifications and native crash capture are not supported on Windows and Linux. diff --git a/README.md b/README.md index 21c574ec..1defbc04 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ Please see the main [PostHog docs](https://posthog.com/docs). Specifically, the [Flutter docs](https://posthog.com/docs/libraries/flutter) details. +## Desktop support + +Windows and Linux support the core analytics SDK. Session replay, surveys, +logs, push notifications, and native crash capture are not supported. If no +user application data directory is available, desktop state uses a private +temporary directory for that registration and is not reused on the next launch. + ## Questions? ### [Check out our community page.](https://posthog.com/posts) diff --git a/posthog_flutter/lib/src/core/PORTING_NOTES.md b/posthog_flutter/lib/src/core/PORTING_NOTES.md index 31afba0a..78c6d808 100644 --- a/posthog_flutter/lib/src/core/PORTING_NOTES.md +++ b/posthog_flutter/lib/src/core/PORTING_NOTES.md @@ -68,6 +68,10 @@ Bootstrap persists, `getSessionId()` remains read-only, and disable does not del The Windows time zone is read during plugin registration through the system ICU from System32. A `TZ` value with an IANA name takes precedence. ICU is included with Windows; the combined icu.dll in use is available from Windows 10 1903. If the library is unavailable or the zone is unknown, the time zone is omitted and the SDK continues to run. This is best effort, with no custom Windows/IANA table or observer for system time zone changes. Sources: [Microsoft ICU](https://learn.microsoft.com/windows/win32/intl/international-components-for-unicode--icu-), [ICU Calendar API](https://unicode-org.github.io/icu-docs/apidoc/dev/icu4c/ucal_8h.html). +Desktop storage uses the user's application data directory. A custom `XDG_DATA_HOME` or `APPDATA` must point to a directory for that user; access to ordinary files is controlled by operating system permissions and umask. + Structured logs, session replay, surveys, push, and native crash hooks are not yet implemented on desktop. A shared Flutter method does not imply transport support on every platform. The shared facade runs beforeSend for logs, but desktop captureLog does not send entries. +Desktop lifecycle is focus-based: Backgrounded and Opened match posthog-macos `ApplicationLifecyclePublisher`'s `applicationDidResignActive` and `applicationDidBecomeActive` notifications (e751c190, lines 97-110). + The desktop adapter also retains three limitations of the current Flutter API on mobile. Consent and the active client are resolved after an asynchronous `beforeSend`, so enabling analytics or replacing the client while the callback is pending can change where the event is sent. Reading a flag while opted out consumes its exposure deduplication entry, as in the native mobile SDKs. Renaming a screen or exception event in `beforeSend` routes it through capture and runs the callback again, as in the Flutter IO adapter. These behaviors need a coordinated cross-platform change rather than a desktop-only contract. diff --git a/posthog_flutter/lib/src/posthog_desktop_lifecycle.dart b/posthog_flutter/lib/src/posthog_desktop_lifecycle.dart index 352a4b09..e13886cf 100644 --- a/posthog_flutter/lib/src/posthog_desktop_lifecycle.dart +++ b/posthog_flutter/lib/src/posthog_desktop_lifecycle.dart @@ -6,9 +6,9 @@ import 'package:flutter/widgets.dart'; import 'posthog_desktop_context.dart'; import 'util/logging.dart'; -/// What the Windows and Linux implementation drives from the application -/// lifecycle. A desktop app has no background state, so it counts as opened -/// when it becomes active and as backgrounded when it resigns active: +/// Lifecycle events and flushes for Windows and Linux. As in PostHog macOS, +/// the app counts as opened when it becomes active and as backgrounded when +/// it resigns active, even if its windows remain visible: /// /// - `Application Installed` / `Application Updated` when this launch's /// version or build differs from the previous launch's; diff --git a/posthog_flutter/lib/src/posthog_desktop_storage.dart b/posthog_flutter/lib/src/posthog_desktop_storage.dart index d1a803be..8d44df0a 100644 --- a/posthog_flutter/lib/src/posthog_desktop_storage.dart +++ b/posthog_flutter/lib/src/posthog_desktop_storage.dart @@ -12,6 +12,10 @@ class DesktopStorage { /// /// The executable name identifies the app because it stays the same across /// releases; renaming the executable starts over with an empty state. + /// + /// If no application data directory is available, creates a private + /// temporary directory for this registration. State there is not reused by + /// later registrations. static String appDirectory( Map environment, { required String executable, @@ -29,12 +33,11 @@ class DesktopStorage { } } if (base == null || base.isEmpty) { - // FileStorage creates the directory itself; the fallback must stay - // scoped or it reopens the shared cross-project store. - base = Directory.systemTemp.path; + base = Directory.systemTemp.createTempSync('posthog_').path; printIfDebug( '[PostHog] No application data directory found; persisting events ' - 'under $base, which the OS may clear at any time.'); + 'in a temporary directory for this registration, which the OS may ' + 'clear at any time.'); } final sep = Platform.pathSeparator; diff --git a/posthog_flutter/test/posthog_desktop_storage_test.dart b/posthog_flutter/test/posthog_desktop_storage_test.dart index de012ac5..90bde73b 100644 --- a/posthog_flutter/test/posthog_desktop_storage_test.dart +++ b/posthog_flutter/test/posthog_desktop_storage_test.dart @@ -51,14 +51,32 @@ void main() { ); }, testOn: '!windows'); - test('is in the temporary directory without an application data one', () { - expect( - DesktopStorage.appDirectory( - const {}, - executable: '/opt/example/example_app', - ), - '${Directory.systemTemp.path}${sep}posthog${sep}example_app', + test('uses a unique private temporary directory without app data', () { + final first = DesktopStorage.appDirectory( + const {}, + executable: '/opt/example/example_app', ); + final second = DesktopStorage.appDirectory( + const {}, + executable: '/opt/example/example_app', + ); + final firstTemporaryDirectory = Directory(first).parent.parent; + final secondTemporaryDirectory = Directory(second).parent.parent; + addTearDown(() { + firstTemporaryDirectory.deleteSync(recursive: true); + secondTemporaryDirectory.deleteSync(recursive: true); + }); + + expect(firstTemporaryDirectory.existsSync(), isTrue); + expect(secondTemporaryDirectory.existsSync(), isTrue); + expect( + firstTemporaryDirectory.path, isNot(secondTemporaryDirectory.path)); + expect(first, startsWith('${firstTemporaryDirectory.path}$sep')); + expect(second, startsWith('${secondTemporaryDirectory.path}$sep')); + if (!Platform.isWindows) { + expect(firstTemporaryDirectory.statSync().mode & 0x1ff, 0x1c0); + expect(secondTemporaryDirectory.statSync().mode & 0x1ff, 0x1c0); + } }); }); From 8fb9bedfefcaf7e3c3d10a2c11bc5fd3a164b985 Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Tue, 29 Sep 2026 14:17:41 +0300 Subject: [PATCH 04/22] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20keep=20desktop=20f?= =?UTF-8?q?allback=20state=20in=20memory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/desktop-dart-sdk.md | 2 +- README.md | 4 +-- posthog_flutter/lib/src/core/PORTING_NOTES.md | 2 ++ .../lib/src/core/file_storage.dart | 23 +++++++++++-- .../lib/src/posthog_desktop_lifecycle.dart | 24 ++++++++------ .../lib/src/posthog_desktop_storage.dart | 14 ++++---- .../lib/src/posthog_flutter_desktop.dart | 14 +++++--- .../test/core/file_storage_test.dart | 20 ++++++++++++ .../test/posthog_desktop_lifecycle_test.dart | 15 ++++++++- .../test/posthog_desktop_storage_test.dart | 32 ++++--------------- .../test/posthog_desktop_transport_test.dart | 22 +++++++++++++ 11 files changed, 119 insertions(+), 53 deletions(-) diff --git a/.changeset/desktop-dart-sdk.md b/.changeset/desktop-dart-sdk.md index 74b4b0e8..0a699210 100644 --- a/.changeset/desktop-dart-sdk.md +++ b/.changeset/desktop-dart-sdk.md @@ -2,4 +2,4 @@ "posthog_flutter": minor --- -Add Windows and Linux support, set up with the same `Posthog().setup(config)` as the other platforms. Event and screen capture, exception capture (including autocapture of Flutter and Dart errors, and exception steps), identify, alias, groups, super properties, feature flags, bootstrap, opt-out and application lifecycle events work on both. The app name, version and build number come from the pubspec as built by Flutter, and the SDK keeps its state in the user's application data directory (`%APPDATA%\posthog` on Windows, `$XDG_DATA_HOME/posthog` or `~/.local/share/posthog` on Linux). If no application data directory is available, state uses a private temporary directory for that registration and is not reused on the next launch. Events include `$timezone` when the SDK can determine a local IANA time zone; on Windows, this uses the system ICU data on a best-effort basis. Session replay, surveys, logs, push notifications and native crash capture are not supported on Windows and Linux. +Add Windows and Linux support, set up with the same `Posthog().setup(config)` as the other platforms. Event and screen capture, exception capture (including autocapture of Flutter and Dart errors, and exception steps), identify, alias, groups, super properties, feature flags, bootstrap, opt-out and application lifecycle events work on both. The app name, version and build number come from the pubspec as built by Flutter, and the SDK keeps its state in the user's application data directory (`%APPDATA%\posthog` on Windows, `$XDG_DATA_HOME/posthog` or `~/.local/share/posthog` on Linux). If no application data directory is available, state stays in memory and is lost when the SDK closes or the app exits. Events include `$timezone` when the SDK can determine a local IANA time zone; on Windows, this uses the system ICU data on a best-effort basis. Session replay, surveys, logs, push notifications and native crash capture are not supported on Windows and Linux. diff --git a/README.md b/README.md index 1defbc04..62920683 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ Specifically, the [Flutter docs](https://posthog.com/docs/libraries/flutter) det Windows and Linux support the core analytics SDK. Session replay, surveys, logs, push notifications, and native crash capture are not supported. If no -user application data directory is available, desktop state uses a private -temporary directory for that registration and is not reused on the next launch. +user application data directory is available, desktop state stays in memory and is lost +when the SDK closes or the app exits. ## Questions? diff --git a/posthog_flutter/lib/src/core/PORTING_NOTES.md b/posthog_flutter/lib/src/core/PORTING_NOTES.md index 78c6d808..1243d253 100644 --- a/posthog_flutter/lib/src/core/PORTING_NOTES.md +++ b/posthog_flutter/lib/src/core/PORTING_NOTES.md @@ -70,6 +70,8 @@ The Windows time zone is read during plugin registration through the system ICU Desktop storage uses the user's application data directory. A custom `XDG_DATA_HOME` or `APPDATA` must point to a directory for that user; access to ordinary files is controlled by operating system permissions and umask. +Without an application data directory, desktop keeps state in memory until the SDK closes or the app exits. It does not create a temporary store. Opened and Backgrounded events still run, but no prior version is available to infer an install or update. + Structured logs, session replay, surveys, push, and native crash hooks are not yet implemented on desktop. A shared Flutter method does not imply transport support on every platform. The shared facade runs beforeSend for logs, but desktop captureLog does not send entries. Desktop lifecycle is focus-based: Backgrounded and Opened match posthog-macos `ApplicationLifecyclePublisher`'s `applicationDidResignActive` and `applicationDidBecomeActive` notifications (e751c190, lines 97-110). diff --git a/posthog_flutter/lib/src/core/file_storage.dart b/posthog_flutter/lib/src/core/file_storage.dart index b660c127..6ae54eb5 100644 --- a/posthog_flutter/lib/src/core/file_storage.dart +++ b/posthog_flutter/lib/src/core/file_storage.dart @@ -5,7 +5,7 @@ import 'logger.dart'; import 'persistence.dart'; import 'uuid.dart'; -/// File-based storage. +/// Client state and queue storage, persistent unless created in memory. /// /// The persisted properties (identity, feature flags, consent, super /// properties) live in one JSON snapshot, `posthog_data.json`. Every queued @@ -36,6 +36,12 @@ import 'uuid.dart'; class FileStorage { FileStorage(this._directory); + FileStorage.memory() + : _directory = '', + _cache = {}, + _role = _Role.secondary, + _queue = FileEventQueue._memory(); + static const _dataFileName = 'posthog_data.json'; static const _lockFileName = 'posthog.lock'; static const _queueDirectoryName = 'posthog_queue'; @@ -281,6 +287,13 @@ class FileEventQueue { {required bool includeExisting}) : _loadedIds = includeExisting ? null : []; + FileEventQueue._memory() + : _directory = '', + _logger = _noLogger, + _loadedIds = []; + + static CoreLogger? _noLogger() => null; + static const _extension = '.json'; final String _directory; @@ -331,6 +344,10 @@ class FileEventQueue { final ids = _ids; final id = generateUuidV7(); ids.add(id); + if (_directory.isEmpty) { + _unwritten[id] = event; + return; + } try { _writeAtomically(File(_pathOf(id)), jsonEncode(event)); } catch (e) { @@ -393,7 +410,9 @@ class FileEventQueue { } void _forget(String id) { - if (_unwritten.remove(id) == null) _delete(File(_pathOf(id))); + if (_unwritten.remove(id) == null && _directory.isNotEmpty) { + _delete(File(_pathOf(id))); + } } void _delete(File file) { diff --git a/posthog_flutter/lib/src/posthog_desktop_lifecycle.dart b/posthog_flutter/lib/src/posthog_desktop_lifecycle.dart index e13886cf..04fffb5b 100644 --- a/posthog_flutter/lib/src/posthog_desktop_lifecycle.dart +++ b/posthog_flutter/lib/src/posthog_desktop_lifecycle.dart @@ -19,7 +19,7 @@ import 'util/logging.dart'; class DesktopAppLifecycle { DesktopAppLifecycle({ required WidgetsBinding? binding, - required String storageDirectory, + required String? storageDirectory, required String? version, required String? build, required bool captureEvents, @@ -32,9 +32,11 @@ class DesktopAppLifecycle { _captureEvents = captureEvents, _capture = capture, _flush = flush, - _versionFile = File( - '$storageDirectory${Platform.pathSeparator}posthog_app_version.json', - ) { + _versionFile = storageDirectory == null + ? null + : File( + '$storageDirectory${Platform.pathSeparator}posthog_app_version.json', + ) { if (binding != null) { _listener = AppLifecycleListener( binding: binding, @@ -54,7 +56,7 @@ class DesktopAppLifecycle { /// Holds the version and build of the latest launch. Kept apart from the /// SDK state so that reset() does not turn the next launch into an install. - final File _versionFile; + final File? _versionFile; AppLifecycleListener? _listener; bool _isStarted = false; @@ -136,13 +138,15 @@ class DesktopAppLifecycle { /// Records this launch's version and build and returns the event reporting /// how they changed since the previous launch, if they did. ({String event, Map properties})? _recordAppVersion() { + final versionFile = _versionFile; + if (versionFile == null) return null; // Nothing to compare when the build recorded neither. if (_version == null && _build == null) return null; Map? previous; try { previous = - jsonDecode(_versionFile.readAsStringSync()) as Map; + jsonDecode(versionFile.readAsStringSync()) as Map; } on PathNotFoundException { // The first launch with the SDK. } catch (e) { @@ -182,15 +186,17 @@ class DesktopAppLifecycle { } void _writeAppVersion(String? version, String? build) { + final versionFile = _versionFile; + if (versionFile == null) return; try { - _versionFile.parent.createSync(recursive: true); + versionFile.parent.createSync(recursive: true); // Atomic replace: a crash mid-write must not lose the recorded version. - final tmp = File('${_versionFile.path}.tmp'); + final tmp = File('${versionFile.path}.tmp'); tmp.writeAsStringSync( jsonEncode({'version': version, 'build': build}), flush: true, ); - tmp.renameSync(_versionFile.path); + tmp.renameSync(versionFile.path); } catch (e) { printIfDebug('[PostHog] Could not record the app version: $e'); } diff --git a/posthog_flutter/lib/src/posthog_desktop_storage.dart b/posthog_flutter/lib/src/posthog_desktop_storage.dart index 8d44df0a..ae480748 100644 --- a/posthog_flutter/lib/src/posthog_desktop_storage.dart +++ b/posthog_flutter/lib/src/posthog_desktop_storage.dart @@ -13,10 +13,9 @@ class DesktopStorage { /// The executable name identifies the app because it stays the same across /// releases; renaming the executable starts over with an empty state. /// - /// If no application data directory is available, creates a private - /// temporary directory for this registration. State there is not reused by - /// later registrations. - static String appDirectory( + /// Returns null when no application data directory is available. The + /// desktop client then keeps its state in memory until it closes. + static String? appDirectory( Map environment, { required String executable, }) { @@ -33,11 +32,10 @@ class DesktopStorage { } } if (base == null || base.isEmpty) { - base = Directory.systemTemp.createTempSync('posthog_').path; printIfDebug( - '[PostHog] No application data directory found; persisting events ' - 'in a temporary directory for this registration, which the OS may ' - 'clear at any time.'); + '[PostHog] No application data directory found; keeping state ' + 'in memory only.'); + return null; } final sep = Platform.pathSeparator; diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index e97f5dfb..847ee85e 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -32,7 +32,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { /// [appDirectory], whose build recorded [appInfo], and that runs in the /// IANA time zone [timezone]. PosthogFlutterDesktop({ - required String appDirectory, + required String? appDirectory, required DesktopAppInfo appInfo, required String? timezone, }) : _appDirectory = appDirectory, @@ -40,7 +40,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { _timezone = timezone; /// See [DesktopStorage.appDirectory]. - final String _appDirectory; + final String? _appDirectory; final DesktopAppInfo _appInfo; @@ -115,13 +115,17 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { } void _startClient(PostHogConfig config) { - final storageDirectory = - DesktopStorage.projectDirectory(_appDirectory, config.projectToken); + final appDirectory = _appDirectory; + final storageDirectory = appDirectory == null + ? null + : DesktopStorage.projectDirectory(appDirectory, config.projectToken); final client = DesktopPostHog( config, staticContext: collectDesktopContext(_appInfo), timezone: _timezone, - storage: FileStorage(storageDirectory), + storage: storageDirectory == null + ? FileStorage.memory() + : FileStorage(storageDirectory), ); _client = client; _featureFlagsUnsubscribe = diff --git a/posthog_flutter/test/core/file_storage_test.dart b/posthog_flutter/test/core/file_storage_test.dart index a1fc74ba..00edf3fb 100644 --- a/posthog_flutter/test/core/file_storage_test.dart +++ b/posthog_flutter/test/core/file_storage_test.dart @@ -10,6 +10,26 @@ import 'test_client.dart'; void main() { group('FileStorage', () { + test('memory storage keeps consent and queued events without disk IO', () { + IOOverrides.runZoned(() { + final storage = FileStorage.memory(); + storage.setProperty(PostHogPersistedProperty.optedOut, true); + storage.queue.add({'event': 'captured'}); + + expect(storage.getProperty(PostHogPersistedProperty.optedOut), + isTrue); + final queued = storage.queue.peek(1).single; + expect(queued.event['event'], 'captured'); + storage.queue.removeOldest(1); + storage.queue.remove([queued.id]); + expect(storage.queue.length, 0); + storage.close(); + }, + createFile: (_) => throw StateError('Unexpected file access'), + createDirectory: (_) => + throw StateError('Unexpected directory access')); + }); + test('setting a property to null removes it from the persisted snapshot', () { final dir = Directory.systemTemp.createTempSync('posthog_storage_null'); diff --git a/posthog_flutter/test/posthog_desktop_lifecycle_test.dart b/posthog_flutter/test/posthog_desktop_lifecycle_test.dart index 89366e52..272f0747 100644 --- a/posthog_flutter/test/posthog_desktop_lifecycle_test.dart +++ b/posthog_flutter/test/posthog_desktop_lifecycle_test.dart @@ -36,10 +36,11 @@ void main() { String? build = '100', bool captureEvents = true, bool withBinding = true, + bool withStorage = true, }) { final lifecycle = DesktopAppLifecycle( binding: withBinding ? WidgetsBinding.instance : null, - storageDirectory: storageDir.path, + storageDirectory: withStorage ? storageDir.path : null, version: version, build: build, captureEvents: captureEvents, @@ -65,6 +66,18 @@ void main() { Map? propertiesOf(String event) => captured.firstWhere((call) => call.event == event).properties; + test('without storage tracks activity without inferring an install', + () async { + createLifecycle(withStorage: false).start(); + expect(captured, isEmpty); + + await moveApp(AppLifecycleState.resumed); + await moveApp(AppLifecycleState.inactive); + + expect(eventNames(), ['Application Opened', 'Application Backgrounded']); + expect(flushes, 1); + }); + group('install and update', () { test('the first launch captures Application Installed', () { createLifecycle().start(); diff --git a/posthog_flutter/test/posthog_desktop_storage_test.dart b/posthog_flutter/test/posthog_desktop_storage_test.dart index 90bde73b..9f1b8cbe 100644 --- a/posthog_flutter/test/posthog_desktop_storage_test.dart +++ b/posthog_flutter/test/posthog_desktop_storage_test.dart @@ -51,32 +51,14 @@ void main() { ); }, testOn: '!windows'); - test('uses a unique private temporary directory without app data', () { - final first = DesktopStorage.appDirectory( - const {}, - executable: '/opt/example/example_app', - ); - final second = DesktopStorage.appDirectory( - const {}, - executable: '/opt/example/example_app', - ); - final firstTemporaryDirectory = Directory(first).parent.parent; - final secondTemporaryDirectory = Directory(second).parent.parent; - addTearDown(() { - firstTemporaryDirectory.deleteSync(recursive: true); - secondTemporaryDirectory.deleteSync(recursive: true); - }); - - expect(firstTemporaryDirectory.existsSync(), isTrue); - expect(secondTemporaryDirectory.existsSync(), isTrue); + test('uses memory-only storage without app data', () { expect( - firstTemporaryDirectory.path, isNot(secondTemporaryDirectory.path)); - expect(first, startsWith('${firstTemporaryDirectory.path}$sep')); - expect(second, startsWith('${secondTemporaryDirectory.path}$sep')); - if (!Platform.isWindows) { - expect(firstTemporaryDirectory.statSync().mode & 0x1ff, 0x1c0); - expect(secondTemporaryDirectory.statSync().mode & 0x1ff, 0x1c0); - } + DesktopStorage.appDirectory( + const {}, + executable: '/opt/example/example_app', + ), + isNull, + ); }); }); diff --git a/posthog_flutter/test/posthog_desktop_transport_test.dart b/posthog_flutter/test/posthog_desktop_transport_test.dart index 1b0fbb46..4cd61cc5 100644 --- a/posthog_flutter/test/posthog_desktop_transport_test.dart +++ b/posthog_flutter/test/posthog_desktop_transport_test.dart @@ -49,6 +49,28 @@ void main() { ..flushInterval = const Duration(hours: 1) ..captureApplicationLifecycleEvents = false; + test('facade captures and honors consent without filesystem access', + () async { + PosthogFlutterPlatformInterface.instance = PosthogFlutterDesktop( + appDirectory: null, + appInfo: const DesktopAppInfo(name: 'memory_test'), + timezone: null, + ); + await IOOverrides.runZoned(() async { + await Posthog().setup(config()..preloadFeatureFlags = false); + await Posthog().disable(); + expect(await Posthog().isOptOut(), isTrue); + await Posthog().capture(eventName: 'blocked event'); + await Posthog().enable(); + await Posthog().capture(eventName: 'memory event'); + await Posthog().flush(); + expect(server.eventNames, ['memory event']); + }, + createFile: (_) => throw StateError('Unexpected file access'), + createDirectory: (_) => + throw StateError('Unexpected directory access')); + }); + test('facade sends a gzipped batch with envelope and event metadata', () async { await Posthog().setup(config(flushAt: 2)..preloadFeatureFlags = false); From bb080a5a3f60f341371c35d17cded33be5177ea1 Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Fri, 2 Oct 2026 22:12:08 +0300 Subject: [PATCH 05/22] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20isolate=20pending?= =?UTF-8?q?=20desktop=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 9 +- LICENSE | 50 ++++ posthog_flutter/lib/src/core/PORTING_NOTES.md | 79 ------ .../lib/src/core/posthog_core.dart | 19 +- .../lib/src/posthog_flutter_desktop.dart | 158 +++++------ posthog_flutter/test/core/bootstrap_test.dart | 62 ++++- .../test/core/feature_flags_test.dart | 88 +++++++ .../test/posthog_error_tracking_web_test.dart | 47 ++++ .../test/posthog_flutter_desktop_test.dart | 245 ++++++++++++++++++ ...posthog_windows_version_resource_test.dart | 30 +++ scripts/check-api-dart.sh | 16 +- 11 files changed, 636 insertions(+), 167 deletions(-) delete mode 100644 posthog_flutter/lib/src/core/PORTING_NOTES.md create mode 100644 posthog_flutter/test/posthog_error_tracking_web_test.dart create mode 100644 posthog_flutter/test/posthog_windows_version_resource_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a63e11b0..eb791988 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -174,7 +174,7 @@ jobs: if: needs.detect-markdown-only.outputs.markdown_only != 'true' timeout-minutes: 5 working-directory: ./posthog_flutter - run: flutter test --platform chrome test/posthog_flutter_web_handler_test.dart test/posthog_flutter_web_setup_test.dart test/posthog_widget_web_test.dart test/posthog_widget_test.dart test/web_before_send_test.dart test/web_canvas_mask_provider_test.dart + run: flutter test --platform chrome test/posthog_flutter_web_handler_test.dart test/posthog_flutter_web_setup_test.dart test/posthog_widget_web_test.dart test/posthog_widget_test.dart test/web_before_send_test.dart test/web_canvas_mask_provider_test.dart test/posthog_error_tracking_web_test.dart # dart2js resolves the isolate-handler conditional import differently; # only a wasm compile exercises the dart2wasm selection this test guards. @@ -217,7 +217,12 @@ jobs: - name: Build Windows example if: needs.detect-markdown-only.outputs.markdown_only != 'true' working-directory: ./example - run: flutter build windows --release + run: flutter build windows --release --build-name=1.2.3 --build-number=456 + + - name: Test Windows version resource + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + working-directory: ./posthog_flutter + run: flutter test --dart-define=POSTHOG_TEST_EXECUTABLE=../example/build/windows/x64/runner/Release/posthog_flutter_example.exe test/posthog_windows_version_resource_test.dart test-build-linux: needs: detect-markdown-only diff --git a/LICENSE b/LICENSE index a4baeef1..4117d1ab 100644 --- a/LICENSE +++ b/LICENSE @@ -46,3 +46,53 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +The desktop core in posthog_flutter/lib/src/core is derived from PostHog/posthog-js-lite (posthog-core). + +Copyright (c) 2022 PostHog (part of Hiberly Inc) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Portions of this codebase are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry + +MIT License + +Copyright (c) 2012 Functional Software, Inc. dba Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/posthog_flutter/lib/src/core/PORTING_NOTES.md b/posthog_flutter/lib/src/core/PORTING_NOTES.md deleted file mode 100644 index 1243d253..00000000 --- a/posthog_flutter/lib/src/core/PORTING_NOTES.md +++ /dev/null @@ -1,79 +0,0 @@ -# Porting notes - -The client behind the Windows and Linux implementation of posthog_flutter -(this directory) is a hand-port of `@posthog/core`. The first port was taken -from the `posthog-core` package of -[posthog-js-lite](https://github.com/PostHog/posthog-js-lite) at commit -`1e2e226c59f3c3a63f780d9b61f6d06304f86903`; that repository is archived and -its core now lives in -[posthog-js `packages/core`](https://github.com/PostHog/posthog-js/tree/main/packages/core), -which later behavior (person profiles, `getFeatureFlagResult`) follows. - -Only what posthog_flutter calls is ported. For example, there is no -`shutdown()`, `createPersonProfile()`, `onFeatureFlag()`, `resetSessionId()` -or `getFeatureFlagPayload()`, and `reset()` keeps no properties but the device -id. - -Deliberate deviations from `@posthog/core`: - -| Behavior | `@posthog/core` | This port | Why | -| --- | --- | --- | --- | -| Requests and persistence | Each platform implements `fetch()`, `getPersistedProperty()` and `setPersistedProperty()` | The client sends requests through `dart:io`'s `HttpClient`, honors `PostHogConfig.compression`, and keeps its state and queue in `FileStorage` | Windows and Linux share one implementation | -| Options | `PostHogCoreOptions`, including request timeouts, retries, session expiration, `disabled` and `disableGeoip` | posthog_flutter's `PostHogConfig`. Requests time out after 10 s, a batch is retried 3 times 3 s apart, and a session ends after 30 minutes without events or after 24 hours | posthog_flutter exposes no other options | -| Batch request retries | A fixed delay between attempts, with no pause between retry cycles | Without `Retry-After`, three retries remain 3 s apart. A positive `Retry-After` pauses the whole queue, which manual flush and flushAt do not bypass; close cancels the timer | The server pause matches Android/iOS; behavior without the header is unchanged | -| `before_send` | Runs on every event, SDK-internal ones included | Not in the client: the Windows and Linux implementation runs `PostHogConfig.beforeSend` on the events the app captures, before they reach the client | SDK-internal events and properties stay out of the callbacks, as on the other platforms of posthog_flutter | -| Quota-limited `/flags` response | Unsets all cached flags | Keeps serving cached flags; `quota_limited` is reported only when there is no cached value | Parity with posthog-android / posthog-ios | -| Person properties on `identify()` / `capture()` | Not fed into flag evaluation | `$set` / `$set_once` merge into the persisted person properties for flags (`$set` wins) | Parity with the mobile SDKs | -| Default person properties for flags | Not supported | Sent with every `/flags` request from the `getDefaultPersonPropertiesForFlags()` override; explicitly set properties win | Parity with the mobile SDKs | -| `getFeatureFlag` of an unknown key | `false` once flags are loaded | `null` | posthog_flutter documents `null` for a missing flag; all platforms must answer alike | -| Stopping the client | `shutdown()` sends the queue first; later calls are still enqueued | `close()` sends nothing: requests in flight are aborted, queued events stay stored for the next client, and later calls are ignored with a debug log | `Posthog().close()` must not wait for the network; parity with posthog-ios `close()` | -| Async init guards | `_initPromise` checked in every accessor | None | Dart construction is synchronous; the object is not observable before the constructor returns | -| Event listeners | `on(event, listener)` for every client event, and a `'*'` wildcard | Only `onFeatureFlags()`; debug mode logs the queued, delivered and failed events | posthog_flutter exposes no other listener | -| Flags known at startup | Listeners wait for the next `/flags` response | Flags that are bootstrapped or cached by an earlier run are announced right after the client is created, unless the outcome of a `/flags` request is announced first | The on-feature-flags spec | -| `identify()` | An empty id falls back to the current one; a different id always switches the user | An empty id is ignored; a different id while identified is ignored until `reset()`; the current id sends `$set` instead of `$identify`, and an identified user's new properties do not reload flags | Parity with posthog-ios / posthog-android | -| Calls while opted out | Identity, groups and person properties still change and flags reload; only the event is dropped | `identify`, `alias`, `group`, `setPersonProperties` and `capture` return before changing anything | Parity with posthog-ios and the consent spec | -| `$set` / `$set_once` in `capture()` | Person processing stays as it was | Turns person processing on, so this and later events are person-processed | Parity with posthog-ios / posthog-android | -| `$groups` in `capture()` | Registered as groups for all later events, reloading flags | Sent with that event only | Parity with posthog-ios / posthog-android | -| `group()` | `$groupidentify` only with group properties, which do not feed flags | `$groupidentify` on every call; group properties also feed flag evaluation | Parity with posthog-ios / posthog-android | -| An empty alias, group type or group key | Sent as it is | The call is ignored, with a debug warning | The alias and group specs | -| `$identify`, `$create_alias`, `$groupidentify` | No `$process_person_profile` / `$is_identified` | Both, like every other event | Parity with posthog-ios | -| Opt-out across `reset()` | Persisted opt-out cleared; the configured default applies at once | Persisted opt-out cleared; this client keeps the decision until it is recreated | Parity with posthog-ios / posthog-android, which keep opt-out in memory | -| Consent while the storage cannot be read | No such state | Unknown consent fails closed; `optIn()` / `optOut()` still apply to the running client, and win over the stored decision once it can be read | Tracking without known consent would be worse than dropping events | -| Two instances of the app at once | Left to each platform's storage | The first instance locks the storage. The second keeps its identity and consent changes in memory only, so a `disable()` made there is lost on restart; its events are still stored and sent | The second instance must not overwrite the identity and consent of the first | -| Session | Persisted and resumed after a restart within the inactivity timeout | Kept in memory; every client starts a new session | Parity with posthog-ios | -| `getSessionId()` | Marks the session active and starts a new one after the timeout | Read-only: only events extend or rotate the session | Parity with posthog-ios `getSessionId()` | -| `$timezone` | Not sent | Sent with every event when the host provides it through `getTimezone()` | Parity with posthog-ios | -| Session debug properties | Not sent | `$sdk_debug_session_start`, `$sdk_debug_current_session_duration`, `$sdk_debug_pending_queue_size` on every event | Parity with posthog-ios / posthog-android | -| `/flags` request | No device id; no retries | `$device_id` (the first anonymous id, kept by `reset()`), `timezone` when a platform overrides `getTimezone()`; one retry after 300 ms on network errors and HTTP 502 / 504 | Parity with posthog-ios and the http-client spec | -| Flag payload that is not valid JSON | Returned as the raw string | `null`, with a debug warning | The get-feature-flag-payload spec | -| `$feature_flag_called` deduplication | Per flag, cleared by every flags reload | Per flag and value, cleared by `reset()` | Parity with posthog-ios / posthog-android | -| `$active_feature_flags` | Every loaded flag, disabled ones included | Enabled flags only; left out when there are none | Parity with posthog-ios / posthog-android | -| Null-valued event properties | Sent as JSON `null` | Left out at every level, `$set` / `$set_once` included; array elements keep their positions. Kept only in the `$feature_flag_response` of a flag that has no value | The capture spec | -| Property values JSON cannot represent | `JSON.stringify` rules | Before they reach the client, the values the platform channels cannot carry become their `toString()`, as on the other platforms of posthog_flutter; a `DateTime` in UTC becomes `2025-01-01 00:00:00.000Z`. The client sends NaN and infinite numbers as `"NaN"` / `"Infinity"`, with a debug warning. Applies to events, exception steps, super properties and flag evaluation properties | posthog_flutter normalizes property values alike on every platform | -| Event queue storage | One persisted property, rewritten with every event | A queue of its own in the storage; `FileStorage` writes one file per event and deletes delivered events by id | Parity with posthog-ios: queueing an event does not rewrite the others | -| `maxQueueSize` / `maxBatchSize` | Raised to at least `flushAt` | Applied as configured. A queue capped below `flushAt` is sent by the periodic flush | Parity with posthog-ios / posthog-android; the retry-queue and event-batcher specs | -| HTTP 3xx from `/batch/` | Treated as delivered | Kept queued and retried like a transient failure | Parity with posthog-ios / posthog-android: a POST redirect is not followed, so the events never reached ingestion | -| Queue left by a previous run | Sent with the first flush after a new event | Sent with the first periodic flush after start | Parity with posthog-ios | -| Bootstrap | Identity seeded only when nothing is persisted; persisted flags win over bootstrapped ones; `$used_bootstrap_value` on every `$feature_flag_called` | An identified bootstrap is also reconciled with a stored user; bootstrapped flags replace persisted ones until the first complete `/flags` response; bootstrap properties only for bootstrapped flags | Parity with posthog-ios and the bootstrap spec | - -## Desktop and mobile platform contracts - -The Flutter public API is shared, but Android and iOS differ in behavior. Regular super properties use `event > session > registered`: this is the contract of `registerForSession`, Android, and the Behavior section of the [register spec](https://github.com/PostHog/sdk-specs/blob/main/openspec/specs/register/spec.md). The [capture spec](https://github.com/PostHog/sdk-specs/blob/main/openspec/specs/capture/spec.md) describes the reverse registered priority used by iOS. Desktop keeps its documented order. The SDK still generates OS/app context, SDK metadata, flags, and person-processing hints. A non-empty caller `$session_id` applies to its event only; the internal session continues to track activity. - -An invalid registered `$groups` remains a custom property, but is not interpreted as membership: only string pairs are used for person processing and flags. Valid maps from previous runs continue to work without migration. The new maxQueueSize applies to a persisted queue before its first send. - -Flags responses support rich `flags` and legacy `featureFlags`/`featureFlagPayloads`. When the rich field is present, it determines the result, even when the map is empty. The same cache, quota, and partial-response rules apply to both formats. - -Bootstrap persists, `getSessionId()` remains read-only, and disable does not delete exception steps that have already been recorded. This matches iOS; exception steps also follow their own spec. `sendFeatureFlagEvents=false` disables exposure events but preserves flags context in capture. An invalid JSON payload returns null according to the payload spec, although mobile implementations may return the original string. - -The Windows time zone is read during plugin registration through the system ICU from System32. A `TZ` value with an IANA name takes precedence. ICU is included with Windows; the combined icu.dll in use is available from Windows 10 1903. If the library is unavailable or the zone is unknown, the time zone is omitted and the SDK continues to run. This is best effort, with no custom Windows/IANA table or observer for system time zone changes. Sources: [Microsoft ICU](https://learn.microsoft.com/windows/win32/intl/international-components-for-unicode--icu-), [ICU Calendar API](https://unicode-org.github.io/icu-docs/apidoc/dev/icu4c/ucal_8h.html). - -Desktop storage uses the user's application data directory. A custom `XDG_DATA_HOME` or `APPDATA` must point to a directory for that user; access to ordinary files is controlled by operating system permissions and umask. - -Without an application data directory, desktop keeps state in memory until the SDK closes or the app exits. It does not create a temporary store. Opened and Backgrounded events still run, but no prior version is available to infer an install or update. - -Structured logs, session replay, surveys, push, and native crash hooks are not yet implemented on desktop. A shared Flutter method does not imply transport support on every platform. The shared facade runs beforeSend for logs, but desktop captureLog does not send entries. - -Desktop lifecycle is focus-based: Backgrounded and Opened match posthog-macos `ApplicationLifecyclePublisher`'s `applicationDidResignActive` and `applicationDidBecomeActive` notifications (e751c190, lines 97-110). - -The desktop adapter also retains three limitations of the current Flutter API on mobile. Consent and the active client are resolved after an asynchronous `beforeSend`, so enabling analytics or replacing the client while the callback is pending can change where the event is sent. Reading a flag while opted out consumes its exposure deduplication entry, as in the native mobile SDKs. Renaming a screen or exception event in `beforeSend` routes it through capture and runs the callback again, as in the Flutter IO adapter. These behaviors need a coordinated cross-platform change rather than a desktop-only contract. diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index 77eb9eb1..b33ada29 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -31,6 +31,9 @@ abstract class PostHogCore extends PostHogCoreStateless { /// The reload that waits for the /flags request in flight. Completer? _pendingFlagsReload; + /// Changes when reset invalidates responses requested for the previous user. + int _featureFlagsGeneration = 0; + final _session = PostHogSessionManager(); /// See [registerForSession]. @@ -74,6 +77,7 @@ abstract class PostHogCore extends PostHogCoreStateless { /// the configured default; this client keeps the current consent decision. void reset() { wrap(() { + _featureFlagsGeneration++; _sessionProps.clear(); _flagCallReported.clear(); _cachedPersonProperties = null; @@ -550,6 +554,7 @@ abstract class PostHogCore extends PostHogCoreStateless { Future _doFlagsAsync() async { _loadingFlags = true; + final generation = _featureFlagsGeneration; try { final distinctId = getDistinctId(); final groupsMap = _getGroups(); @@ -579,6 +584,8 @@ abstract class PostHogCore extends PostHogCoreStateless { extraPayload: extraProperties, ); + if (generation != _featureFlagsGeneration) return; + if (result is GetFlagsFailure) { final stored = _getStoredFlagDetails(); _setKnownFeatureFlagDetails(PostHogFlagsStorageFormat( @@ -680,9 +687,11 @@ abstract class PostHogCore extends PostHogCoreStateless { /// Serves the enabled bootstrapped flags until the first complete `/flags` /// response. They replace any flags persisted by an earlier session. void _seedBootstrapFlags(PostHogBootstrapConfig bootstrap) { + final bootstrapFlags = bootstrap.featureFlags; + if (bootstrapFlags == null || bootstrapFlags.isEmpty) return; + final flags = {}; - for (final MapEntry(:key, :value) - in (bootstrap.featureFlags ?? {}).entries) { + for (final MapEntry(:key, :value) in bootstrapFlags.entries) { // Only enabled flags are served: false and '' are dropped. if (value == true || (value is String && value.isNotEmpty)) { flags[key] = value; @@ -693,7 +702,6 @@ abstract class PostHogCore extends PostHogCoreStateless { value.runtimeType); } } - if (flags.isEmpty) return; final payloads = {}; final details = {}; @@ -763,8 +771,9 @@ abstract class PostHogCore extends PostHogCoreStateless { true; final featureFlag = details?.flags[key]; final flagValue = getFeatureFlagValue(featureFlag); - final shouldSendEvent = - sendEvent && !(_flagCallReported[key]?.contains(flagValue) ?? false); + final shouldSendEvent = sendEvent && + !optedOut && + !(_flagCallReported[key]?.contains(flagValue) ?? false); if (shouldSendEvent) { final errors = []; diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 847ee85e..31ae0122 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import 'core/file_storage.dart'; +import 'core/utils/utils.dart'; import 'error_tracking/dart_exception_processor.dart'; import 'feature_flag_result.dart'; import 'logs/posthog_log_severity.dart'; @@ -21,6 +22,8 @@ import 'utils/before_send.dart'; import 'utils/capture_utils.dart'; import 'utils/property_normalizer.dart'; +typedef _ClientScope = ({String projectToken, String host}); + /// The Windows and Linux implementation, built on the pure-Dart /// [DesktopPostHog] client. /// @@ -48,6 +51,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { DesktopPostHog? _client; + _ClientScope? _clientScope; + /// The configuration of the latest setup(), read by the Dart-side hooks: /// beforeSend, onFeatureFlags and exception processing. PostHogConfig? _config; @@ -70,6 +75,21 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return client; } + DesktopPostHog? _clientForContinuation( + String op, + _ClientScope scope, + String distinctId, + ) { + final client = _clientFor(op); + if (client == null || + _clientScope != scope || + client.optedOut || + client.getDistinctId() != distinctId) { + return null; + } + return client; + } + Future _guard( String op, FutureOr Function(DesktopPostHog client) fn, @@ -128,6 +148,10 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { : FileStorage(storageDirectory), ); _client = client; + _clientScope = ( + projectToken: config.projectToken, + host: removeTrailingSlash(config.host), + ); _featureFlagsUnsubscribe = client.onFeatureFlags(() => _config?.onFeatureFlags?.call()); @@ -198,8 +222,12 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Map? userProperties, Map? userPropertiesSetOnce, }) => - _guard('capture', (_) async { + _guard('capture', (client) async { + if (client.optedOut) return; if (!_hasName(eventName)) return; + final scope = _clientScope; + if (scope == null) return; + final distinctId = client.getDistinctId(); final processed = await _runBeforeSend( eventName, @@ -211,24 +239,10 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); return; } - // A callback may have renamed the event. - if (!_hasName(processed.event)) return; - - // Resolved after the callbacks, which may be async: a client closed - // meanwhile must not write to the on-disk state again. - final client = _clientFor('capture'); - if (client == null) return; - client.capture( - processed.event, - properties: _withExceptionSteps( - processed.event, - _mergeUserProps( - processed.properties, - processed.userProperties, - processed.userPropertiesSetOnce, - ), - ), - ); + + final current = _clientForContinuation('capture', scope, distinctId); + if (current == null) return; + _captureProcessed(current, processed); }); @override @@ -240,6 +254,9 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { // Opted out, nothing happens: no callback runs, and the screen does // not become the current one. if (client.optedOut) return; + final scope = _clientScope; + if (scope == null) return; + final distinctId = client.getDistinctId(); final processed = await _runBeforeSend( PostHogEventName.screen, @@ -254,13 +271,12 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return; } - // A renamed event is no longer a screen view, so it is captured as a - // regular event. + final current = _clientForContinuation('screen', scope, distinctId); + if (current == null) return; + + // A renamed event is no longer a screen view. if (processed.event != PostHogEventName.screen) { - await capture( - eventName: processed.event, - properties: processed.properties, - ); + _captureProcessed(current, processed); return; } @@ -274,19 +290,14 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return; } - // Resolved again after the callbacks, as in capture(). - final current = _clientFor('screen'); - if (current == null) return; // Later events that set no `$screen_name` of their own carry this one. current.registerForSession( {PostHogPropertyName.screenName: finalScreenName}); - current.capture( - PostHogEventName.screen, - properties: { - ...?_normalize(processed.properties), - PostHogPropertyName.screenName: finalScreenName, - }, - ); + processed.properties = { + ...?processed.properties, + PostHogPropertyName.screenName: finalScreenName, + }; + _captureProcessed(current, processed); }); /// Structured logs are not supported on the desktop implementation. @@ -445,7 +456,12 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { StackTrace? stackTrace, Map? properties, }) => - _guard('captureException', (_) async { + _guard('captureException', (client) async { + if (client.optedOut) return; + final scope = _clientScope; + if (scope == null) return; + final distinctId = client.getDistinctId(); + final exceptionProps = DartExceptionProcessor.processException( error: error, stackTrace: stackTrace, @@ -466,25 +482,10 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return; } - // A renamed event is no longer an exception, so it is captured as a - // regular event. - if (processed.event != PostHogEventName.exception) { - await capture( - eventName: processed.event, - properties: processed.properties, - ); - return; - } - - final client = _clientFor('captureException'); - if (client == null) return; - client.capture( - PostHogEventName.exception, - properties: _withExceptionSteps( - PostHogEventName.exception, - _normalize(processed.properties), - ), - ); + final current = + _clientForContinuation('captureException', scope, distinctId); + if (current == null) return; + _captureProcessed(current, processed); }); @override @@ -502,6 +503,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { @override Future close() => _guard('close', (client) { _client = null; + _clientScope = null; _exceptionSteps = null; _appLifecycle?.dispose(); _appLifecycle = null; @@ -552,30 +554,30 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, - }) async { - var event = PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ); + }) => + applyBeforeSend( + _config?.beforeSend ?? const [], + PostHogEvent( + event: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ), + ); - final callbacks = _config?.beforeSend ?? const []; - for (final callback in callbacks) { - try { - final result = await runBeforeSend(callback, event); - if (result == null) return null; - event = result; - } catch (e) { - // A callback that fails may be the one scrubbing sensitive data, so - // neither the original nor a partially processed event is sent. - printIfDebug( - '[PostHog] Warning: beforeSend callback threw an exception; dropping event: $e', - ); - return null; - } - } - return event; + void _captureProcessed(DesktopPostHog client, PostHogEvent event) { + if (!_hasName(event.event)) return; + client.capture( + event.event, + properties: _withExceptionSteps( + event.event, + _mergeUserProps( + event.properties, + event.userProperties, + event.userPropertiesSetOnce, + ), + ), + ); } /// Whether [event] has a name; an event without one is dropped. diff --git a/posthog_flutter/test/core/bootstrap_test.dart b/posthog_flutter/test/core/bootstrap_test.dart index a40e7a9b..27e4cc41 100644 --- a/posthog_flutter/test/core/bootstrap_test.dart +++ b/posthog_flutter/test/core/bootstrap_test.dart @@ -165,6 +165,56 @@ void main() { expect(client.getFeatureFlag('other'), isNull); }); + test('a supplied disabled-only snapshot clears stored flags', () async { + server.respond = (_) => + _flagsResponse({'stale': _flag('stale', payload: '{"secret":true}')}); + final (earlier, _) = launch(); + await earlier.reloadFeatureFlagsAsync(); + expect( + earlier.getFeatureFlagResult('stale', sendEvent: false)?.payload, + {'secret': true}, + ); + earlier.close(); + + final (client, storage) = launch(const PostHogBootstrapConfig( + featureFlags: {'disabled': false, 'empty': ''}, + featureFlagPayloads: {'disabled': 'hidden'}, + )); + final announced = Completer(); + client.onFeatureFlags(announced.complete); + + await announced.future; + + expect(client.getFeatureFlag('stale'), isNull); + expect(client.getFeatureFlag('disabled'), isNull); + expect( + client.getFeatureFlagResult('stale', sendEvent: false)?.payload, + isNull, + ); + expect( + storage.getProperty>( + PostHogPersistedProperty.featureFlagDetails, + ), + {'flags': {}}, + ); + }); + + for (final (description, bootstrap) in [ + ('absent', const PostHogBootstrapConfig()), + ('empty', const PostHogBootstrapConfig(featureFlags: {})), + ]) { + test('an $description snapshot leaves stored flags unchanged', () async { + server.respond = (_) => _flagsResponse({'stored': _flag('stored')}); + final (earlier, _) = launch(); + await earlier.reloadFeatureFlagsAsync(); + earlier.close(); + + final (client, _) = launch(bootstrap); + + expect(client.getFeatureFlag('stored'), isTrue); + }); + } + test('are replaced by a complete flags response', () async { final (client, _) = launch(const PostHogBootstrapConfig(featureFlags: { 'beta-ui': 'variant-a', @@ -286,12 +336,20 @@ bool _isIdentified(FileStorage storage) => storage.getProperty(PostHogPersistedProperty.personMode) == 'identified'; -Map _flag(String key, - {bool enabled = true, String? variant}) => +Map _flag( + String key, { + bool enabled = true, + String? variant, + String? payload, +}) => { 'key': key, 'enabled': enabled, if (variant != null) 'variant': variant, + if (payload != null) + 'metadata': { + 'payload': payload, + }, }; PostHogResponse _flagsResponse( diff --git a/posthog_flutter/test/core/feature_flags_test.dart b/posthog_flutter/test/core/feature_flags_test.dart index cd8c50ea..4dce5aa0 100644 --- a/posthog_flutter/test/core/feature_flags_test.dart +++ b/posthog_flutter/test/core/feature_flags_test.dart @@ -119,6 +119,78 @@ void main() { expect(server.flagsRequests, hasLength(2)); }); + test('a successful response before reset stays discarded when reload fails', + () async { + final dir = tempDirectory(); + final storage = FileStorage(dir.path); + final client = testClient(server, storage: storage); + final staleResponse = Completer(); + final resetResponse = Completer(); + var request = 0; + server.respond = + (_) => ++request == 1 ? staleResponse.future : resetResponse.future; + final announced = []; + client.onFeatureFlags(() => announced.add( + client.getFeatureFlagResult('stale', sendEvent: false)?.enabled)); + + final inFlight = client.reloadFeatureFlagsAsync(); + final oldRequest = await server.waitForFlagsRequest(0); + client.reset(); + final anonymousId = client.getDistinctId(); + final afterReset = client.reloadFeatureFlagsAsync(); + staleResponse.complete(_flagsResponse( + {'stale': _flag('stale', payload: '{"secret":true}')})); + + await inFlight; + final anonymousRequest = await server.waitForFlagsRequest(1); + expect(announced, isEmpty, + reason: 'the response for the reset identity must have no effects'); + expect(client.getFeatureFlag('stale'), isNull); + expect(anonymousRequest['distinct_id'], anonymousId); + expect(anonymousRequest['distinct_id'], isNot(oldRequest['distinct_id'])); + + resetResponse + .complete(const PostHogResponse(HttpStatus.internalServerError)); + await afterReset; + + expect(announced, [null], + reason: 'only the failed reload after reset is announced'); + expect(server.flagsRequests, hasLength(2)); + + client.close(); + final reopened = testClient(server, storage: FileStorage(dir.path)); + + expect(reopened.getFeatureFlag('stale'), isNull); + expect(reopened.getFeatureFlagResult('stale', sendEvent: false)?.payload, + isNull); + }); + + test('a failed response started before reset has no effects', () async { + final storage = tempStorage(); + final client = testClient(server, storage: storage); + final staleResponse = Completer(); + var request = 0; + server.respond = (_) => ++request == 1 + ? staleResponse.future + : _flagsResponse({'fresh': _flag('fresh')}); + final announced = []; + client.onFeatureFlags(() => announced.add( + client.getFeatureFlagResult('fresh', sendEvent: false)?.enabled)); + + final inFlight = client.reloadFeatureFlagsAsync(); + await server.waitForFlagsRequest(0); + client.reset(); + final afterReset = client.reloadFeatureFlagsAsync(); + staleResponse + .complete(const PostHogResponse(HttpStatus.internalServerError)); + + await Future.wait([inFlight, afterReset]); + + expect(announced, [true]); + expect(_storedFlagDetails(storage).containsKey('requestError'), isFalse); + expect(server.flagsRequests, hasLength(2)); + }); + test('a throwing onFeatureFlags callback does not stop the flags', () async { final client = testClient(server); @@ -493,6 +565,22 @@ void main() { expect(_flagCalledCount(storage), 2); }); + test('does not deduplicate an exposure suppressed while opted out', + () async { + await client.reloadFeatureFlagsAsync(); + client.optOut(); + + expect(client.getFeatureFlag('beta-ui'), isTrue, + reason: 'local flag reads remain available while opted out'); + expect(_flagCalledCount(storage), 0); + + client.optIn(); + expect(client.getFeatureFlag('beta-ui'), isTrue); + expect(client.getFeatureFlag('beta-ui'), isTrue); + + expect(_flagCalledCount(storage), 1); + }); + test('carries whether the flag is linked to an experiment', () async { flags = {'beta-ui': _flag('beta-ui', hasExperiment: true)}; await client.reloadFeatureFlagsAsync(); diff --git a/posthog_flutter/test/posthog_error_tracking_web_test.dart b/posthog_flutter/test/posthog_error_tracking_web_test.dart new file mode 100644 index 00000000..a3e7662d --- /dev/null +++ b/posthog_flutter/test/posthog_error_tracking_web_test.dart @@ -0,0 +1,47 @@ +@TestOn('browser') +library; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; +import 'package:posthog_flutter/src/posthog_config.dart'; + +import 'posthog_flutter_platform_interface_fake.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('platform error capture is skipped without disabling Flutter errors', + () { + final originalHandler = FlutterError.onError; + final originalPlatformHandler = PlatformDispatcher.instance.onError; + var forwarded = 0; + FlutterError.onError = (_) => forwarded++; + addTearDown(() { + PostHogErrorTrackingAutoCaptureIntegration.uninstall(); + FlutterError.onError = originalHandler; + }); + final posthog = PosthogFlutterPlatformFake(); + final config = PostHogErrorTrackingConfig() + ..captureFlutterErrors = true + ..capturePlatformDispatcherErrors = true; + + for (var i = 0; i < 2; i++) { + final integration = PostHogErrorTrackingAutoCaptureIntegration.install( + config: config, + posthog: posthog, + ); + expect(integration, isNotNull); + expect( + PlatformDispatcher.instance.onError, same(originalPlatformHandler)); + FlutterError.onError!(FlutterErrorDetails( + exception: StateError('web error'), + stack: StackTrace.current, + )); + PostHogErrorTrackingAutoCaptureIntegration.uninstall(); + } + + expect(posthog.capturedExceptions, hasLength(2)); + expect(forwarded, 2); + }); +} diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 11ee3575..2c00d973 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -188,6 +188,32 @@ void main() { ); }); + test('does not admit a capture made while opted out', () async { + final server = await LocalPostHogServer.start(); + final callbackEvents = []; + final releaseHook = Completer(); + final config = configFor(server) + ..beforeSend = [ + (event) async { + callbackEvents.add(event.event); + if (event.event == 'blocked capture') await releaseHook.future; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.disable(); + final blocked = platform.capture(eventName: 'blocked capture'); + await platform.enable(); + releaseHook.complete(); + await blocked; + await platform.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + expect(server.eventNames, ['sentinel event']); + expect(callbackEvents, ['sentinel event']); + }); + test(r'reports the time zone as $timezone and to /flags', () async { final server = await LocalPostHogServer.start(); final platform = createPlatform(timezone: 'Europe/Berlin'); @@ -287,6 +313,57 @@ void main() { reason: 'the screen did not become the current one'); }); + test('does not remember a screen when consent changes during its hook', + () async { + final server = await LocalPostHogServer.start(); + final hookStarted = Completer(); + final releaseHook = Completer(); + final config = configFor(server) + ..beforeSend = [ + (event) async { + if (event.event == r'$screen') { + hookStarted.complete(); + await releaseHook.future; + } + return event; + }, + ]; + final platform = await setUpPlatform(config); + + final screen = platform.screen(screenName: 'Blocked'); + await hookStarted.future; + await platform.disable(); + releaseHook.complete(); + await screen; + await platform.enable(); + await platform.capture(eventName: 'sentinel event'); + + final event = await server.waitForEvent('sentinel event'); + expect(server.eventNames, ['sentinel event']); + expect(_propertiesOf(event), isNot(contains(r'$screen_name'))); + }); + + test('runs the hook once when it renames a screen event', () async { + final server = await LocalPostHogServer.start(); + var callbackCount = 0; + final config = configFor(server) + ..beforeSend = [ + (event) { + callbackCount++; + event.event = 'renamed screen'; + event.userProperties = {'source': 'screen hook'}; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.screen(screenName: 'Checkout'); + + final event = await server.waitForEvent('renamed screen'); + expect(callbackCount, 1); + expect(_propertiesOf(event)[r'$set'], {'source': 'screen hook'}); + }); + test('keeps the screen name when a callback rebuilds the properties', () async { final server = await LocalPostHogServer.start(); @@ -540,6 +617,53 @@ void main() { Map.from(exception['stacktrace']! as Map); expect(stacktrace['frames'], isNotEmpty); }); + + test('does not admit an exception made while opted out', () async { + final server = await LocalPostHogServer.start(); + final callbackEvents = []; + final releaseHook = Completer(); + final config = configFor(server) + ..beforeSend = [ + (event) async { + callbackEvents.add(event.event); + if (event.event == r'$exception') await releaseHook.future; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.disable(); + final blocked = platform.captureException(error: StateError('blocked')); + await platform.enable(); + releaseHook.complete(); + await blocked; + await platform.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + expect(server.eventNames, ['sentinel event']); + expect(callbackEvents, ['sentinel event']); + }); + + test('runs the hook once when it renames an exception event', () async { + final server = await LocalPostHogServer.start(); + var callbackCount = 0; + final config = configFor(server) + ..beforeSend = [ + (event) { + callbackCount++; + event.event = 'renamed exception'; + event.userProperties = {'source': 'exception hook'}; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + await platform.captureException(error: StateError('renamed')); + + final event = await server.waitForEvent('renamed exception'); + expect(callbackCount, 1); + expect(_propertiesOf(event)[r'$set'], {'source': 'exception hook'}); + }); }); group('Error autocapture', () { @@ -970,6 +1094,127 @@ void main() { expect(server.eventNames, ['slow event', 'sentinel event']); }); + final pendingCalls = Function(PosthogFlutterDesktop) call, + })>{ + 'capture': ( + eventName: 'pending capture', + call: (platform) => platform.capture(eventName: 'pending capture'), + ), + 'screen': ( + eventName: r'$screen', + call: (platform) => platform.screen(screenName: 'Pending'), + ), + 'captureException': ( + eventName: r'$exception', + call: (platform) => + platform.captureException(error: StateError('pending')), + ), + }; + + for (final entry in pendingCalls.entries) { + test('${entry.key} pending in a hook does not cross project scope', + () async { + final server = await LocalPostHogServer.start(); + final hookStarted = Completer(); + final releaseHook = Completer(); + final config = configFor(server) + ..beforeSend = [ + (event) async { + hookStarted.complete(); + await releaseHook.future; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + final pending = entry.value.call(platform); + await hookStarted.future; + await platform.close(); + await platform.setup( + configFor(server, projectToken: 'next_project'), + ); + releaseHook.complete(); + await pending; + await platform.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + expect(server.eventNames, ['sentinel event']); + }); + } + + for (final entry in pendingCalls.entries) { + test('${entry.key} pending in a hook does not cross host scope', + () async { + final firstServer = await LocalPostHogServer.start(); + final nextServer = await LocalPostHogServer.start(); + final hookStarted = Completer(); + final releaseHook = Completer(); + final config = configFor(firstServer) + ..beforeSend = [ + (event) async { + hookStarted.complete(); + await releaseHook.future; + return event; + }, + ]; + final platform = await setUpPlatform(config); + + final pending = entry.value.call(platform); + await hookStarted.future; + await platform.close(); + await platform.setup(configFor(nextServer)); + releaseHook.complete(); + await pending; + await platform.capture(eventName: 'sentinel event'); + + await nextServer.waitForEvent('sentinel event'); + expect(firstServer.eventNames, isNot(contains(entry.value.eventName))); + expect(nextServer.eventNames, ['sentinel event']); + }); + } + + for (final identityChange in ['identify', 'reset']) { + for (final entry in pendingCalls.entries) { + test('${entry.key} pending in a hook does not cross $identityChange', + () async { + final server = await LocalPostHogServer.start(); + final hookStarted = Completer(); + final releaseHook = Completer(); + final config = configFor(server) + ..beforeSend = [ + (event) async { + if (event.event == entry.value.eventName) { + hookStarted.complete(); + await releaseHook.future; + } + return event; + }, + ]; + final platform = await setUpPlatform(config); + final initialDistinctId = await platform.getDistinctId(); + + final pending = entry.value.call(platform); + await hookStarted.future; + if (identityChange == 'identify') { + await platform.identify(userId: 'identified_user'); + expect(await platform.getDistinctId(), 'identified_user'); + } else { + await platform.reset(); + expect(await platform.getDistinctId(), isNot(initialDistinctId)); + } + releaseHook.complete(); + await pending; + await platform.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + expect(server.eventNames, isNot(contains(entry.value.eventName))); + }); + } + } + test('isOptOut reports opted out after close', () async { final server = await LocalPostHogServer.start(); final platform = await setUpPlatform( diff --git a/posthog_flutter/test/posthog_windows_version_resource_test.dart b/posthog_flutter/test/posthog_windows_version_resource_test.dart new file mode 100644 index 00000000..692be859 --- /dev/null +++ b/posthog_flutter/test/posthog_windows_version_resource_test.dart @@ -0,0 +1,30 @@ +@TestOn('windows') +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_desktop_app_info.dart'; +import 'package:posthog_flutter/src/posthog_desktop_version_resource.dart'; + +void main() { + const executable = String.fromEnvironment('POSTHOG_TEST_EXECUTABLE'); + + test('reads the built runner version resource from a Unicode path', () { + final directory = Directory.systemTemp.createTempSync('posthog_é_日本_'); + addTearDown(() => directory.deleteSync(recursive: true)); + final copied = File(executable).copySync('${directory.path}/runner_é.exe'); + + final resource = WindowsVersionResource.read(copied.path); + expect(resource.productName, 'posthog_flutter_example'); + expect(resource.productVersion, '1.2.3+456'); + + final info = DesktopAppInfo.fromVersionResource( + productName: resource.productName, + productVersion: resource.productVersion, + ); + expect(info.name, 'posthog_flutter_example'); + expect(info.version, '1.2.3'); + expect(info.build, '456'); + }, skip: executable.isEmpty ? 'Requires the built Windows example.' : false); +} diff --git a/scripts/check-api-dart.sh b/scripts/check-api-dart.sh index 35b24b01..fad9574b 100755 --- a/scripts/check-api-dart.sh +++ b/scripts/check-api-dart.sh @@ -45,7 +45,7 @@ dart run dart_apitool:main extract \ dart run "$ROOT_DIR/scripts/annotate-api-dart.dart" "$RAW_API" "$ROOT_DIR/posthog_flutter" -python3 - "$RAW_API" "$GENERATED_API" <<'PY' +python3 - "$RAW_API" "$GENERATED_API" "$SNAPSHOT_FILE" <<'PY' import json import sys from pathlib import Path @@ -58,6 +58,20 @@ package_api = data.get("packageApi", {}) package_api.pop("packagePath", None) # Keep the checked-in public API snapshot independent from release bumps. package_api["packageVersion"] = "" +# File enumeration order differs between platforms. Preserve the baseline's +# declaration order without sorting parameters or other ordered API members. +snapshot_path = Path(sys.argv[3]) +baseline = json.loads(snapshot_path.read_text()).get("packageApi", {}) if snapshot_path.exists() else {} +for collection in ("interfaceDeclarations", "executableDeclarations", "fieldDeclarations", "typeAliasDeclarations"): + order = { + (item.get("relativePath", ""), item["name"]): index + for index, item in enumerate(baseline.get(collection, [])) + } + package_api.get(collection, []).sort(key=lambda item: ( + order.get((item.get("relativePath", ""), item["name"]), len(order)), + item.get("relativePath", ""), + item["name"], + )) out_path.write_text(json.dumps(data, indent=4, sort_keys=True) + "\n") PY From 5818b2000ec90e1d871dfce52ef92bdbbef9645f Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Fri, 2 Oct 2026 22:43:52 +0300 Subject: [PATCH 06/22] =?UTF-8?q?=F0=9F=90=9B=20[fix]=20use=20platform=20a?= =?UTF-8?q?pplication=20support=20directories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 +- api/posthog_flutter.api.json | 12 ++ posthog_flutter/lib/posthog_flutter_dart.dart | 5 +- .../lib/src/posthog_desktop_storage.dart | 58 ++----- .../lib/src/posthog_flutter_desktop.dart | 82 ++++++---- posthog_flutter/pubspec.yaml | 4 + .../test/posthog_desktop_storage_test.dart | 93 ++++++----- .../test/posthog_desktop_transport_test.dart | 4 +- .../test/posthog_flutter_dart_test.dart | 116 ++++++++++++-- .../test/posthog_flutter_desktop_test.dart | 151 +++++++++++++++++- pubspec.lock | 40 +++++ 11 files changed, 441 insertions(+), 137 deletions(-) diff --git a/README.md b/README.md index 62920683..a65a8c4b 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,17 @@ Specifically, the [Flutter docs](https://posthog.com/docs/libraries/flutter) det ## Desktop support Windows and Linux support the core analytics SDK. Session replay, surveys, -logs, push notifications, and native crash capture are not supported. If no -user application data directory is available, desktop state stays in memory and is lost +logs, push notifications, and native crash capture are not supported. + +Desktop state is stored under `posthog/` in the application +support directory returned by Flutter's Windows/Linux `path_provider` +implementations. Keep the application's storage identity stable across releases: +Windows uses the company/product metadata, and Linux uses the application ID, +with executable-name fallbacks +when that metadata is unavailable. + +Await `Posthog().setup(config)` to finish initialization. If the application +support directory is unavailable, desktop state stays in memory and is lost when the SDK closes or the app exits. ## Questions? diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index 9443e5f6..672bd887 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -6023,6 +6023,18 @@ "iosPlatformConstraints": null, "minSdkVersion": "3.6.0", "packageDependencies": [ + { + "packageName": "path_provider_linux", + "packageVersion": "^2.2.1" + }, + { + "packageName": "path_provider_platform_interface", + "packageVersion": "^2.1.2" + }, + { + "packageName": "path_provider_windows", + "packageVersion": "^2.3.0" + }, { "packageName": "plugin_platform_interface", "packageVersion": "^2.0.2" diff --git a/posthog_flutter/lib/posthog_flutter_dart.dart b/posthog_flutter/lib/posthog_flutter_dart.dart index 66220dd0..9f945aff 100644 --- a/posthog_flutter/lib/posthog_flutter_dart.dart +++ b/posthog_flutter/lib/posthog_flutter_dart.dart @@ -16,10 +16,7 @@ class PosthogFlutterDart { static void registerWith() { final environment = Platform.environment; PosthogFlutterPlatformInterface.instance = PosthogFlutterDesktop( - appDirectory: DesktopStorage.appDirectory( - environment, - executable: Platform.resolvedExecutable, - ), + appDirectory: DesktopStorage.appDirectory, appInfo: DesktopAppInfo.fromPlatform(), timezone: DesktopTimeZone.read(environment), ); diff --git a/posthog_flutter/lib/src/posthog_desktop_storage.dart b/posthog_flutter/lib/src/posthog_desktop_storage.dart index ae480748..eb08cfb1 100644 --- a/posthog_flutter/lib/src/posthog_desktop_storage.dart +++ b/posthog_flutter/lib/src/posthog_desktop_storage.dart @@ -1,45 +1,30 @@ import 'dart:io'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; + import 'util/logging.dart'; /// Where the Windows and Linux implementation persists its state: queued /// events, identity, consent and cached feature flags. class DesktopStorage { - /// The directory of the app at [executable] in the user's application data - /// directory: `%APPDATA%\posthog\` on Windows, - /// `$XDG_DATA_HOME/posthog/` (or under - /// `~/.local/share`) on Linux, read from [environment]. - /// - /// The executable name identifies the app because it stays the same across - /// releases; renaming the executable starts over with an empty state. - /// - /// Returns null when no application data directory is available. The - /// desktop client then keeps its state in memory until it closes. - static String? appDirectory( - Map environment, { - required String executable, - }) { - String? base; - if (Platform.isWindows) { - base = environment['APPDATA'] ?? environment['LOCALAPPDATA']; - } else { - base = environment['XDG_DATA_HOME']; - if (base == null || base.isEmpty) { - final home = environment['HOME']; - if (home != null && home.isNotEmpty) { - base = '$home/.local/share'; - } + /// The PostHog directory inside the platform's application support directory. + /// Returns null when directory lookup or creation fails, so the client can + /// keep its state in memory. + static Future appDirectory() async { + try { + final path = + await PathProviderPlatform.instance.getApplicationSupportPath(); + if (path == null) { + printIfDebug('[PostHog] No application support directory found; ' + 'keeping state in memory only.'); + return null; } - } - if (base == null || base.isEmpty) { - printIfDebug( - '[PostHog] No application data directory found; keeping state ' - 'in memory only.'); + return '$path${Platform.pathSeparator}posthog'; + } catch (e) { + printIfDebug('[PostHog] Application support directory unavailable; ' + 'keeping state in memory only: $e'); return null; } - - final sep = Platform.pathSeparator; - return '$base${sep}posthog$sep${_scope(_executableName(executable))}'; } /// The directory of the project with [projectToken] in [appDirectory]: a @@ -48,15 +33,6 @@ class DesktopStorage { static String projectDirectory(String appDirectory, String projectToken) => '$appDirectory${Platform.pathSeparator}${_scope(projectToken)}'; - /// The file name of [executable] without the `.exe` extension of Windows - /// executables. - static String _executableName(String executable) { - final name = executable.split(RegExp(r'[/\\]')).last; - return name.toLowerCase().endsWith('.exe') - ? name.substring(0, name.length - '.exe'.length) - : name; - } - static String _scope(String value) { final scope = value.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_'); if (scope.isEmpty || scope == '.' || scope == '..') return 'default'; diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 31ae0122..a14a308c 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -32,10 +32,10 @@ typedef _ClientScope = ({String projectToken, String host}); /// after close() are ignored, with a debug warning. class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { /// Creates the implementation for an app that keeps its state in - /// [appDirectory], whose build recorded [appInfo], and that runs in the - /// IANA time zone [timezone]. + /// the directory resolved by [appDirectory], whose build recorded [appInfo], + /// and that runs in the IANA time zone [timezone]. PosthogFlutterDesktop({ - required String? appDirectory, + required Future Function() appDirectory, required DesktopAppInfo appInfo, required String? timezone, }) : _appDirectory = appDirectory, @@ -43,7 +43,11 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { _timezone = timezone; /// See [DesktopStorage.appDirectory]. - final String? _appDirectory; + final Future Function() _appDirectory; + + Future? _setupFuture; + + int _setupGeneration = 0; final DesktopAppInfo _appInfo; @@ -104,6 +108,12 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { FutureOr Function(DesktopPostHog client) fn, ) async { try { + final setup = _setupFuture; + if (_client == null && setup != null) { + final generation = _setupGeneration; + await setup; + if (generation != _setupGeneration) return fallback; + } final client = _clientFor(op); return client == null ? fallback : await fn(client); } catch (e) { @@ -113,29 +123,31 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { } @override - Future setup(PostHogConfig config) async { - try { - _setup(config); - } catch (e) { - printIfDebug('[PostHog] Exception on setup: $e'); - } - } - - void _setup(PostHogConfig config) { + Future setup(PostHogConfig config) { // The Dart-side hooks follow every setup(), while a running client keeps // its configuration until close(). _config = config; - if (_client != null) { + if (_client != null || _setupFuture != null) { printIfDebug('[PostHog] Setup called despite already being setup!'); - return; + return _setupFuture ?? Future.value(); } - // Created synchronously, so calls made right after an unawaited setup() - // already reach the client. - _startClient(config); + return _setupFuture = _setup(config, _setupGeneration); } - void _startClient(PostHogConfig config) { - final appDirectory = _appDirectory; + Future _setup(PostHogConfig config, int generation) async { + try { + final appDirectory = await _appDirectory(); + // A close() during directory lookup must not create another client. + if (generation != _setupGeneration) return; + _startClient(config, appDirectory); + } catch (e) { + printIfDebug('[PostHog] Exception on setup: $e'); + } finally { + if (generation == _setupGeneration) _setupFuture = null; + } + } + + void _startClient(PostHogConfig config, String? appDirectory) { final storageDirectory = appDirectory == null ? null : DesktopStorage.projectDirectory(appDirectory, config.projectToken); @@ -501,19 +513,23 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { }); @override - Future close() => _guard('close', (client) { - _client = null; - _clientScope = null; - _exceptionSteps = null; - _appLifecycle?.dispose(); - _appLifecycle = null; - _featureFlagsUnsubscribe?.call(); - _featureFlagsUnsubscribe = null; - - // Nothing is sent on close: queued events stay on disk and go out - // with the next client. - client.close(); - }); + Future close() { + _setupGeneration++; + _setupFuture = null; + return _guard('close', (client) { + _client = null; + _clientScope = null; + _exceptionSteps = null; + _appLifecycle?.dispose(); + _appLifecycle = null; + _featureFlagsUnsubscribe?.call(); + _featureFlagsUnsubscribe = null; + + // Nothing is sent on close: queued events stay on disk and go out + // with the next client. + client.close(); + }); + } @override Future getSessionId() => diff --git a/posthog_flutter/pubspec.yaml b/posthog_flutter/pubspec.yaml index c8871efe..2f28d0cd 100644 --- a/posthog_flutter/pubspec.yaml +++ b/posthog_flutter/pubspec.yaml @@ -17,6 +17,10 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter + # Mobile SDKs own their storage; only the desktop implementations are needed. + path_provider_linux: ^2.2.1 + path_provider_platform_interface: ^2.1.2 + path_provider_windows: ^2.3.0 plugin_platform_interface: ^2.0.2 # plugin_platform_interface depends on meta anyway meta: ^1.3.0 diff --git a/posthog_flutter/test/posthog_desktop_storage_test.dart b/posthog_flutter/test/posthog_desktop_storage_test.dart index 9f1b8cbe..1eca61a7 100644 --- a/posthog_flutter/test/posthog_desktop_storage_test.dart +++ b/posthog_flutter/test/posthog_desktop_storage_test.dart @@ -1,64 +1,60 @@ import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:posthog_flutter/src/posthog_desktop_storage.dart'; void main() { final sep = Platform.pathSeparator; group('DesktopStorage.appDirectory', () { - // The application data directory: APPDATA on Windows, XDG_DATA_HOME on - // Linux. - const environment = {'APPDATA': '/data', 'XDG_DATA_HOME': '/data'}; + late Directory supportDirectory; - test('is named after the executable in the application data directory', () { - expect( - DesktopStorage.appDirectory( - environment, - executable: '/opt/example/example_app', - ), - '/data${sep}posthog${sep}example_app', - ); + setUp(() { + supportDirectory = Directory.systemTemp.createTempSync('posthog_support'); + final previous = PathProviderPlatform.instance; + PathProviderPlatform.instance = + _PathProvider(() async => supportDirectory.path); + addTearDown(() { + PathProviderPlatform.instance = previous; + supportDirectory.deleteSync(recursive: true); + }); }); - test('leaves out the .exe extension', () { - expect( - DesktopStorage.appDirectory( - environment, - executable: r'C:\Program Files\Example\Example.EXE', - ), - '/data${sep}posthog${sep}Example', - ); + test('uses the application support directory with a PostHog subdirectory', + () async { + expect(await DesktopStorage.appDirectory(), + '${supportDirectory.path}${sep}posthog'); }); - test('replaces characters that do not belong in a directory name', () { - expect( - DesktopStorage.appDirectory( - environment, - executable: '/opt/example/example app', - ), - '/data${sep}posthog${sep}example_app', - ); + test('preserves spaces and Unicode in the application directory', () async { + final path = '${supportDirectory.path}${sep}Example é 日本'; + PathProviderPlatform.instance = _PathProvider(() async => path); + expect(await DesktopStorage.appDirectory(), '$path${sep}posthog'); }); - test('is under ~/.local/share without XDG_DATA_HOME', () { - expect( - DesktopStorage.appDirectory( - {'HOME': '/home/user'}, - executable: '/opt/example/example_app', - ), - '/home/user/.local/share/posthog/example_app', - ); - }, testOn: '!windows'); + test('uses memory-only storage when no directory is available', () async { + PathProviderPlatform.instance = _PathProvider(() async => null); + expect(await DesktopStorage.appDirectory(), isNull); + }); - test('uses memory-only storage without app data', () { - expect( - DesktopStorage.appDirectory( - const {}, - executable: '/opt/example/example_app', - ), - isNull, - ); + test('uses memory-only storage when the platform lookup fails', () async { + PathProviderPlatform.instance = _PathProvider( + () async => throw PlatformException(code: 'unavailable')); + expect(await DesktopStorage.appDirectory(), isNull); + }); + + test('uses memory-only storage when the directory cannot be created', + () async { + final file = File('${supportDirectory.path}${sep}file') + ..writeAsStringSync(''); + PathProviderPlatform.instance = _PathProvider(() async { + final directory = Directory('${file.path}${sep}child'); + await directory.create(recursive: true); + return directory.path; + }); + expect(await DesktopStorage.appDirectory(), isNull); }); }); @@ -82,3 +78,12 @@ void main() { }); }); } + +class _PathProvider extends PathProviderPlatform { + _PathProvider(this.resolve); + + final Future Function() resolve; + + @override + Future getApplicationSupportPath() => resolve(); +} diff --git a/posthog_flutter/test/posthog_desktop_transport_test.dart b/posthog_flutter/test/posthog_desktop_transport_test.dart index 4cd61cc5..2c8dbfbd 100644 --- a/posthog_flutter/test/posthog_desktop_transport_test.dart +++ b/posthog_flutter/test/posthog_desktop_transport_test.dart @@ -27,7 +27,7 @@ void main() { server = await LocalPostHogServer.start(); previousPlatform = PosthogFlutterPlatformInterface.instance; PosthogFlutterPlatformInterface.instance = PosthogFlutterDesktop( - appDirectory: appDirectory.path, + appDirectory: () async => appDirectory.path, appInfo: const DesktopAppInfo( name: 'transport_test', version: '1.2.3', @@ -52,7 +52,7 @@ void main() { test('facade captures and honors consent without filesystem access', () async { PosthogFlutterPlatformInterface.instance = PosthogFlutterDesktop( - appDirectory: null, + appDirectory: () async => null, appInfo: const DesktopAppInfo(name: 'memory_test'), timezone: null, ); diff --git a/posthog_flutter/test/posthog_flutter_dart_test.dart b/posthog_flutter/test/posthog_flutter_dart_test.dart index bddbbade..4f7a8698 100644 --- a/posthog_flutter/test/posthog_flutter_dart_test.dart +++ b/posthog_flutter/test/posthog_flutter_dart_test.dart @@ -1,24 +1,120 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; import 'package:posthog_flutter/posthog_flutter_dart.dart'; +import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_flutter_desktop.dart'; import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; +import 'posthog_api_fake.dart'; import 'posthog_flutter_platform_interface_fake.dart'; void main() { - test('registerWith installs the Windows and Linux implementation', () { - // Seed a known instance first: reading the uninitialized default would - // construct the method-channel implementation, which needs a Flutter - // binding. - final previous = PosthogFlutterPlatformFake(); - PosthogFlutterPlatformInterface.instance = previous; - addTearDown(() => PosthogFlutterPlatformInterface.instance = previous); + late Directory supportDirectory; + late _PathProvider pathProvider; + + setUp(() { + supportDirectory = Directory.systemTemp.createTempSync('posthog_plugin'); + final previousProvider = PathProviderPlatform.instance; + pathProvider = _PathProvider(supportDirectory.path); + PathProviderPlatform.instance = pathProvider; + // Reading the default platform would construct the method-channel + // implementation, which needs a Flutter binding. + PosthogFlutterPlatformInterface.instance = PosthogFlutterPlatformFake(); + final previousPlatform = PosthogFlutterPlatformInterface.instance; + addTearDown(() async { + await PosthogFlutterPlatformInterface.instance.close(); + PosthogFlutterPlatformInterface.instance = previousPlatform; + PathProviderPlatform.instance = previousProvider; + supportDirectory.deleteSync(recursive: true); + }); + }); + PostHogConfig configFor(LocalPostHogServer server) => + PostHogConfig('phc_test') + ..host = server.url + ..preloadFeatureFlags = false + ..captureApplicationLifecycleEvents = false + ..flushAt = 20; + + test('registerWith installs the implementation without resolving storage', + () { PosthogFlutterDart.registerWith(); + expect( + PosthogFlutterPlatformInterface.instance, isA()); + expect(pathProvider.lookups, 0); + }); + test( + 'setup resolves application support storage and restores identity and queue', + () async { + final server = await LocalPostHogServer.start(); + PosthogFlutterDart.registerWith(); + var platform = PosthogFlutterPlatformInterface.instance; + await platform.setup(configFor(server)); + expect(pathProvider.lookups, 1); + final distinctId = await platform.getDistinctId(); + await platform.capture(eventName: 'persisted event'); + await platform.close(); + final sep = Platform.pathSeparator; expect( - PosthogFlutterPlatformInterface.instance, - isA(), - ); + Directory('${supportDirectory.path}${sep}posthog${sep}phc_test') + .existsSync(), + isTrue); + + PosthogFlutterDart.registerWith(); + platform = PosthogFlutterPlatformInterface.instance; + await platform.setup(configFor(server)); + expect(await platform.getDistinctId(), distinctId); + await platform.capture(eventName: 'new event'); + await platform.flush(); + expect(server.eventNames, ['persisted event', 'new event']); }); + + test('a new registration restores the persisted opt-out', () async { + final server = await LocalPostHogServer.start(); + PosthogFlutterDart.registerWith(); + var platform = PosthogFlutterPlatformInterface.instance; + await platform.setup(configFor(server)); + await platform.disable(); + await platform.close(); + + PosthogFlutterDart.registerWith(); + platform = PosthogFlutterPlatformInterface.instance; + await platform.setup(configFor(server)); + expect(await platform.isOptOut(), isTrue); + await platform.capture(eventName: 'disabled event'); + await platform.flush(); + expect(server.events, isEmpty); + }); + + test('directory lookup failure leaves a working memory-only client', + () async { + final server = await LocalPostHogServer.start(); + pathProvider.fail = true; + PosthogFlutterDart.registerWith(); + final platform = PosthogFlutterPlatformInterface.instance; + await platform.setup(configFor(server)); + await platform.capture(eventName: 'memory event'); + await platform.flush(); + expect(server.eventNames, ['memory event']); + expect(supportDirectory.listSync(), isEmpty); + }); +} + +class _PathProvider extends PathProviderPlatform { + _PathProvider(this.path); + + final String path; + int lookups = 0; + bool fail = false; + + @override + Future getApplicationSupportPath() async { + lookups++; + if (fail) throw PlatformException(code: 'unavailable'); + return path; + } } diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 2c00d973..d66fd8b7 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -26,11 +26,13 @@ void main() { /// that runs in [timezone]. PosthogFlutterDesktop createPlatform({ Directory? appDirectory, + Future Function()? resolveAppDirectory, DesktopAppInfo appInfo = const DesktopAppInfo(), String? timezone, }) { + final directory = appDirectory ?? createAppDirectory(); final platform = PosthogFlutterDesktop( - appDirectory: (appDirectory ?? createAppDirectory()).path, + appDirectory: resolveAppDirectory ?? () async => directory.path, appInfo: appInfo, timezone: timezone, ); @@ -987,6 +989,153 @@ void main() { }); group('PosthogFlutterDesktop.setup', () { + test('waits for storage before identify and capture', () async { + final server = await LocalPostHogServer.start(); + final directory = createAppDirectory(); + final release = Completer(); + final platform = + createPlatform(resolveAppDirectory: () => release.future); + final setup = platform.setup(configFor(server)); + final identify = platform.identify(userId: 'early-user'); + final capture = platform.capture(eventName: 'early event'); + var completed = false; + capture.then((_) => completed = true); + await Future.delayed(Duration.zero); + expect(completed, isFalse); + expect(server.events, isEmpty); + + release.complete(directory.path); + await Future.wait([setup, identify, capture]); + final event = await server.waitForEvent('early event'); + expect(event['distinct_id'], 'early-user'); + }); + + test('disable during storage lookup applies before subsequent events', + () async { + final server = await LocalPostHogServer.start(); + final release = Completer(); + final platform = + createPlatform(resolveAppDirectory: () => release.future); + final setup = platform.setup(configFor(server)); + final disable = platform.disable(); + final capture = platform.capture(eventName: 'disabled event'); + release.complete(createAppDirectory().path); + await Future.wait([setup, disable, capture]); + expect(await platform.isOptOut(), isTrue); + await platform.enable(); + await platform.capture(eventName: 'enabled event'); + await server.waitForEvent('enabled event'); + expect(server.eventNames, ['enabled event']); + }); + + test('an early capture respects the opt-out restored from disk', () async { + final server = await LocalPostHogServer.start(); + final directory = createAppDirectory(); + final previousLaunch = createPlatform(appDirectory: directory); + await previousLaunch.setup(configFor(server)); + await previousLaunch.disable(); + await previousLaunch.close(); + + final release = Completer(); + final platform = + createPlatform(resolveAppDirectory: () => release.future); + final setup = platform.setup(configFor(server)); + final capture = platform.capture(eventName: 'early event'); + release.complete(directory.path); + await Future.wait([setup, capture]); + expect(await platform.isOptOut(), isTrue); + await platform.enable(); + await platform.capture(eventName: 'enabled event'); + await server.waitForEvent('enabled event'); + expect(server.eventNames, ['enabled event']); + }); + + test('overlapping setup shares storage lookup and applies latest hooks', + () async { + final server = await LocalPostHogServer.start(); + final otherServer = await LocalPostHogServer.start(); + final release = Completer(); + var lookups = 0; + final platform = createPlatform(resolveAppDirectory: () { + lookups++; + return release.future; + }); + final first = platform.setup(configFor(server)); + final second = + platform.setup(configFor(otherServer, projectToken: 'other') + ..beforeSend = [ + (event) => event..properties = {'hook': 'latest'} + ]); + release.complete(createAppDirectory().path); + await Future.wait([first, second]); + await platform.capture(eventName: 'event'); + final event = await server.waitForEvent('event'); + expect(_propertiesOf(event)['hook'], 'latest'); + expect(otherServer.events, isEmpty); + expect(otherServer.flagsRequests, isEmpty); + expect(lookups, 1); + }); + + test('close cancels pending setup and waiting operations', () async { + final server = await LocalPostHogServer.start(); + final directory = createAppDirectory(); + final release = Completer(); + final platform = + createPlatform(resolveAppDirectory: () => release.future); + final setup = platform.setup(configFor(server)); + final capture = platform.capture(eventName: 'cancelled event'); + await platform.close(); + release.complete(directory.path); + await Future.wait([setup, capture]); + expect(await platform.getDistinctId(), isEmpty); + expect(directory.listSync(), isEmpty); + expect(server.events, isEmpty); + expect(server.flagsRequests, isEmpty); + }); + + test('a cancelled lookup cannot replace a new client', () async { + final server = await LocalPostHogServer.start(); + final otherServer = await LocalPostHogServer.start(); + final release = Completer(); + final oldDirectory = createAppDirectory(); + final nextDirectory = createAppDirectory(); + var lookups = 0; + final platform = createPlatform(resolveAppDirectory: () { + return ++lookups == 1 + ? release.future + : Future.value(nextDirectory.path); + }); + final first = platform.setup(configFor(server)); + final identify = platform.identify(userId: 'cancelled-user'); + final capture = platform.capture(eventName: 'cancelled event'); + await platform.close(); + await platform.setup(configFor(otherServer, projectToken: 'other')); + final distinctId = await platform.getDistinctId(); + release.complete(oldDirectory.path); + await Future.wait([first, identify, capture]); + expect(await platform.getDistinctId(), distinctId); + await platform.capture(eventName: 'new client event'); + await otherServer.waitForEvent('new client event'); + expect(otherServer.eventNames, ['new client event']); + expect(server.events, isEmpty); + expect(server.flagsRequests, isEmpty); + expect(oldDirectory.listSync(), isEmpty); + }); + + test('missing storage still creates a working memory-only client', + () async { + final server = await LocalPostHogServer.start(); + final platform = createPlatform(resolveAppDirectory: () async => null); + await platform.setup(configFor(server)); + await platform.identify(userId: 'memory-user'); + await platform.capture(eventName: 'memory event'); + final event = await server.waitForEvent('memory event'); + expect(event['distinct_id'], 'memory-user'); + await platform.close(); + await platform.setup(configFor(server)); + expect(await platform.getDistinctId(), isNot('memory-user')); + }); + test('calls made right after an unawaited setup reach the client', () async { final server = await LocalPostHogServer.start(); diff --git a/pubspec.lock b/pubspec.lock index b15b5be6..0864013c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -368,6 +368,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -376,6 +400,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" plist_parser: dependency: transitive description: @@ -605,6 +637,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.26.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" xml: dependency: transitive description: From 36ae4d336e6268448c3d03066f04040c1fc11b3b Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Fri, 2 Oct 2026 23:03:06 +0300 Subject: [PATCH 07/22] =?UTF-8?q?=F0=9F=93=9D=20[docs]=20remove=20unrelate?= =?UTF-8?q?d=20license=20notice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/LICENSE b/LICENSE index 4117d1ab..3d1d44c1 100644 --- a/LICENSE +++ b/LICENSE @@ -70,29 +70,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---- - -Portions of this codebase are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry - -MIT License - -Copyright (c) 2012 Functional Software, Inc. dba Sentry - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. From 177d576c8e4955dc890927d632c1b35137625494 Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Fri, 2 Oct 2026 23:38:46 +0300 Subject: [PATCH 08/22] =?UTF-8?q?=F0=9F=90=9B=20[fix]:=20preserve=20identi?= =?UTF-8?q?ty=20after=20read=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/src/core/file_storage.dart | 31 ++++--- .../test/core/file_storage_test.dart | 24 ++++- posthog_flutter/test/core/opt_out_test.dart | 91 ++++++++++++++++++- 3 files changed, 126 insertions(+), 20 deletions(-) diff --git a/posthog_flutter/lib/src/core/file_storage.dart b/posthog_flutter/lib/src/core/file_storage.dart index 6ae54eb5..166344b8 100644 --- a/posthog_flutter/lib/src/core/file_storage.dart +++ b/posthog_flutter/lib/src/core/file_storage.dart @@ -29,10 +29,12 @@ import 'uuid.dart'; /// /// Storage never throws into the host app. While the snapshot cannot be /// read, the store reports [isDegraded] and keeps writes in memory only, so a -/// transient failure never replaces good persisted data; once the file can -/// be read again, its content wins. Files are replaced through a temporary -/// file and a rename, without syncing them to disk: a crash can lose the -/// latest writes but never leaves a file half-written. +/// transient failure never replaces good persisted data. That instance stays +/// in memory after a read failure because it cannot safely merge later runtime +/// changes with a snapshot it never saw; a new instance retries the disk. +/// Files are replaced through a temporary file and a rename, without syncing +/// them to disk: a crash can lose the latest writes but never leaves a file +/// half-written. class FileStorage { FileStorage(this._directory); @@ -54,10 +56,10 @@ class FileStorage { final String _directory; Map? _cache; + bool _readFailed = false; - /// Values set while the snapshot cannot be read. They are never written - /// over the file, whose content is unknown, and give way to it once it can - /// be read. + /// Values set after the snapshot could not be read. They are never written + /// over the file, whose content is unknown, and remain this instance's state. final Map _unpersisted = {}; // Losing the whole snapshot (ids, consent) matters enough to log outside @@ -79,7 +81,7 @@ class FileStorage { String get _dataFilePath => _pathOf(_dataFileName); - /// Whether the snapshot is currently unreadable. + /// Whether this instance failed to read its snapshot. /// /// While degraded, persisted state (including consent) is unknown - /// consumers should treat it conservatively, e.g. consent checks fail @@ -160,14 +162,15 @@ class FileStorage { _lock = null; } - /// Returns the store, or null while the disk is unreadable. + /// Returns the store, or null after this instance failed to read it. Map? _readAll() { if (_cache != null) return _cache; - final snapshot = _cache = _readSnapshot(); - if (snapshot != null && _unpersisted.isNotEmpty) { - logger?.warn('The PostHog storage file can be read again; values set ' - 'while it could not be read give way to the stored ones.'); - _unpersisted.clear(); + if (_readFailed) return null; + final snapshot = _readSnapshot(); + if (snapshot == null) { + _readFailed = true; + } else { + _cache = snapshot; } return snapshot; } diff --git a/posthog_flutter/test/core/file_storage_test.dart b/posthog_flutter/test/core/file_storage_test.dart index 00edf3fb..7b0f3ea8 100644 --- a/posthog_flutter/test/core/file_storage_test.dart +++ b/posthog_flutter/test/core/file_storage_test.dart @@ -248,7 +248,8 @@ void main() { }, skip: chmodSkip); } - test('writes while the disk is unreadable stay in memory, disk intact', () { + test('an unreadable snapshot keeps the instance in memory, disk intact', + () { final dir = Directory.systemTemp.createTempSync('posthog_storage_rd'); final dataFile = '${dir.path}/posthog_data.json'; addTearDown(() { @@ -276,12 +277,19 @@ void main() { expect(lines, [contains('in memory only')]); chmod('644', dataFile); - expect(blind.isDegraded, isFalse, - reason: 'degradation describes the disk, not the instance: it ' - 'must lift as soon as the disk is readable again'); + expect(blind.isDegraded, isTrue, + reason: 'mixing a snapshot whose contents were unknown with ' + 'runtime writes could restore stale identity and consent'); expect(blind.getProperty(PostHogPersistedProperty.distinctId), - 'keep'); + 'in-memory'); expect(blind.getProperty(PostHogPersistedProperty.anonymousId), + isNull); + + final reopened = FileStorage(dir.path); + addTearDown(reopened.close); + expect(reopened.getProperty(PostHogPersistedProperty.distinctId), + 'keep'); + expect(reopened.getProperty(PostHogPersistedProperty.anonymousId), 'anon'); }, skip: chmodSkip); @@ -305,7 +313,13 @@ void main() { isNull); chmod('755', sub.path); + expect(blind.isDegraded, isTrue); expect(blind.getProperty(PostHogPersistedProperty.distinctId), + isNull); + + final reopened = FileStorage(sub.path); + addTearDown(reopened.close); + expect(reopened.getProperty(PostHogPersistedProperty.distinctId), 'keep'); }, skip: chmodSkip); }); diff --git a/posthog_flutter/test/core/opt_out_test.dart b/posthog_flutter/test/core/opt_out_test.dart index 661c72c4..b43fdb1a 100644 --- a/posthog_flutter/test/core/opt_out_test.dart +++ b/posthog_flutter/test/core/opt_out_test.dart @@ -96,6 +96,13 @@ void main() { reason: 'with consent unknown, tracking without it would be worse ' 'than dropping events'); + chmod('644', dataFile); + expect(storage.isDegraded, isTrue); + client.capture('after disk recovery'); + expect(getQueue(storage), isEmpty, + reason: 'a recovered file cannot retroactively establish consent ' + 'for an instance that never read its snapshot'); + final lines = printedLines(() { client.optIn(); client.capture('first'); @@ -107,7 +114,6 @@ void main() { reason: 'an explicit opt-in applies although it cannot be stored'); expect(queuedMessage(storage, 1)['distinct_id'], queuedMessage(storage, 0)['distinct_id']); - chmod('644', dataFile); final stored = FileStorage(dir.path); expect(stored.getProperty(PostHogPersistedProperty.anonymousId), 'stored-anon'); @@ -135,6 +141,89 @@ void main() { reason: 'the opt-out made while the file could not be read wins ' 'over the stored opt-in'); }, skip: chmodSkip); + + test('identity set while storage is unreadable survives disk recovery', () { + final dir = Directory.systemTemp.createTempSync('posthog_identity'); + final dataFile = '${dir.path}/posthog_data.json'; + addTearDown(() { + chmod('644', dataFile); + dir.deleteSync(recursive: true); + }); + FileStorage(dir.path) + ..setProperty(PostHogPersistedProperty.anonymousId, 'alice-anon') + ..setProperty(PostHogPersistedProperty.distinctId, 'alice') + ..setProperty(PostHogPersistedProperty.personMode, 'identified') + ..setProperty(PostHogPersistedProperty.optedOut, true) + ..close(); + final originalSnapshot = File(dataFile).readAsStringSync(); + chmod('000', dataFile); + final storage = FileStorage(dir.path); + final client = testClient(server, storage: storage); + + expect(client.optedOut, isTrue); + client.optIn(); + client.identify('bob'); + expect(client.getDistinctId(), 'bob'); + + chmod('644', dataFile); + client.capture('after recovery'); + + expect(client.getDistinctId(), 'bob'); + expect(client.optedOut, isFalse); + final recoveredEvent = getQueue(storage) + .singleWhere((message) => message['event'] == 'after recovery'); + expect(recoveredEvent['distinct_id'], 'bob'); + expect(File(dataFile).readAsStringSync(), originalSnapshot); + + client.close(); + final restarted = FileStorage(dir.path); + addTearDown(restarted.close); + expect(restarted.getProperty(PostHogPersistedProperty.distinctId), + 'alice'); + expect(restarted.getProperty(PostHogPersistedProperty.personMode), + 'identified'); + expect(restarted.getProperty(PostHogPersistedProperty.optedOut), + isTrue); + }, skip: chmodSkip); + + test('reset while storage is unreadable stays cleared after recovery', () { + final dir = Directory.systemTemp.createTempSync('posthog_reset'); + final dataFile = '${dir.path}/posthog_data.json'; + addTearDown(() { + chmod('644', dataFile); + dir.deleteSync(recursive: true); + }); + FileStorage(dir.path) + ..setProperty(PostHogPersistedProperty.anonymousId, 'alice-anon') + ..setProperty(PostHogPersistedProperty.distinctId, 'alice') + ..setProperty(PostHogPersistedProperty.personMode, 'identified') + ..setProperty(PostHogPersistedProperty.props, {'plan': 'old'}) + ..setProperty(PostHogPersistedProperty.featureFlagDetails, { + 'flags': { + 'old-flag': {'key': 'old-flag', 'enabled': true}, + }, + }) + ..close(); + chmod('000', dataFile); + final storage = FileStorage(dir.path); + final client = testClient(server, storage: storage); + + client.optIn(); + client.reset(); + final resetDistinctId = client.getDistinctId(); + chmod('644', dataFile); + + expect(client.getDistinctId(), resetDistinctId); + expect(client.getDistinctId(), isNot('alice')); + expect( + storage.getProperty>( + PostHogPersistedProperty.props), + isNull); + expect( + storage.getProperty>( + PostHogPersistedProperty.featureFlagDetails), + isNull); + }, skip: chmodSkip); }); group('Calls while opted out', () { From 4a7201867333a3fe5be1cb7c2c91c0e1d5d6d019 Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Fri, 2 Oct 2026 23:38:50 +0300 Subject: [PATCH 09/22] =?UTF-8?q?=F0=9F=90=9B=20[fix]:=20retain=20original?= =?UTF-8?q?=20exception=20step=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/src/posthog_flutter_desktop.dart | 25 +++++--- .../test/posthog_flutter_desktop_test.dart | 63 +++++++++++++++++++ 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index a14a308c..948d4cee 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -240,6 +240,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final scope = _clientScope; if (scope == null) return; final distinctId = client.getDistinctId(); + final exceptionSteps = _exceptionSteps; final processed = await _runBeforeSend( eventName, @@ -254,7 +255,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('capture', scope, distinctId); if (current == null) return; - _captureProcessed(current, processed); + _captureProcessed(current, processed, exceptionSteps); }); @override @@ -269,6 +270,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final scope = _clientScope; if (scope == null) return; final distinctId = client.getDistinctId(); + final exceptionSteps = _exceptionSteps; final processed = await _runBeforeSend( PostHogEventName.screen, @@ -288,7 +290,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { // A renamed event is no longer a screen view. if (processed.event != PostHogEventName.screen) { - _captureProcessed(current, processed); + _captureProcessed(current, processed, exceptionSteps); return; } @@ -309,7 +311,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ...?processed.properties, PostHogPropertyName.screenName: finalScreenName, }; - _captureProcessed(current, processed); + _captureProcessed(current, processed, exceptionSteps); }); /// Structured logs are not supported on the desktop implementation. @@ -473,6 +475,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final scope = _clientScope; if (scope == null) return; final distinctId = client.getDistinctId(); + final exceptionSteps = _exceptionSteps; final exceptionProps = DartExceptionProcessor.processException( error: error, @@ -497,7 +500,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final current = _clientForContinuation('captureException', scope, distinctId); if (current == null) return; - _captureProcessed(current, processed); + _captureProcessed(current, processed, exceptionSteps); }); @override @@ -581,12 +584,17 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ), ); - void _captureProcessed(DesktopPostHog client, PostHogEvent event) { + void _captureProcessed( + DesktopPostHog client, + PostHogEvent event, + ExceptionStepsBuffer? exceptionSteps, + ) { if (!_hasName(event.event)) return; client.capture( event.event, properties: _withExceptionSteps( event.event, + exceptionSteps, _mergeUserProps( event.properties, event.userProperties, @@ -603,13 +611,14 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return false; } - /// Adds the recorded exception steps to an `$exception` event that does not - /// set its own. + /// Uses the originating client's steps even when a same-scope restart + /// happened during beforeSend. Explicit event steps take precedence. Map? _withExceptionSteps( String event, + ExceptionStepsBuffer? exceptionSteps, Map? properties, ) { - final steps = _exceptionSteps?.steps; + final steps = exceptionSteps?.steps; if (event != PostHogEventName.exception || steps == null || steps.isEmpty) { return properties; } diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index d66fd8b7..e3564bfc 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -732,6 +732,69 @@ void main() { expect(stepMessagesOf(sentinel), isNull); }); + for (final call in ['capture', 'screen', 'captureException']) { + for (final enabled in [true, false]) { + test( + '$call keeps its original exception steps across close/setup ' + '(initially enabled: $enabled)', () async { + final server = await LocalPostHogServer.start(); + final entered = Completer(); + final release = Completer(); + final config = configFor(server) + ..errorTrackingConfig.exceptionSteps.enabled = enabled + ..beforeSend = [ + (event) async { + entered.complete(); + await release.future; + event.event = r'$exception'; + return event; + }, + ]; + final platform = await setUpPlatform(config); + await platform.addExceptionStep('original client'); + + final pending = switch (call) { + 'capture' => platform.capture(eventName: 'custom error'), + 'screen' => platform.screen(screenName: 'Error'), + _ => platform.captureException(error: StateError('pending')), + }; + await entered.future; + await platform.close(); + await platform.setup(configFor(server)); + await platform.addExceptionStep('replacement client'); + release.complete(); + await pending; + + final event = await server.waitForEvent(r'$exception'); + expect(stepMessagesOf(event), enabled ? ['original client'] : null); + }); + } + } + + test('includes steps recorded while the same client awaits beforeSend', + () async { + final server = await LocalPostHogServer.start(); + final entered = Completer(); + final release = Completer(); + final platform = await setUpPlatform(configFor(server) + ..beforeSend = [ + (event) async { + entered.complete(); + await release.future; + return event; + }, + ]); + await platform.addExceptionStep('before capture'); + final pending = platform.captureException(error: StateError('pending')); + await entered.future; + await platform.addExceptionStep('during beforeSend'); + release.complete(); + await pending; + + expect(stepMessagesOf(await server.waitForEvent(r'$exception')), + ['before capture', 'during beforeSend']); + }); + test('send their values as event properties send them', () async { final server = await LocalPostHogServer.start(); final platform = await setUpPlatform(configFor(server)); From 53e8d33390b73872294d6b450072d815efde6f27 Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Fri, 2 Oct 2026 23:38:53 +0300 Subject: [PATCH 10/22] =?UTF-8?q?=F0=9F=93=9D=20[docs]:=20correct=20deskto?= =?UTF-8?q?p=20storage=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/desktop-dart-sdk.md | 2 +- README.md | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.changeset/desktop-dart-sdk.md b/.changeset/desktop-dart-sdk.md index 0a699210..5be601f8 100644 --- a/.changeset/desktop-dart-sdk.md +++ b/.changeset/desktop-dart-sdk.md @@ -2,4 +2,4 @@ "posthog_flutter": minor --- -Add Windows and Linux support, set up with the same `Posthog().setup(config)` as the other platforms. Event and screen capture, exception capture (including autocapture of Flutter and Dart errors, and exception steps), identify, alias, groups, super properties, feature flags, bootstrap, opt-out and application lifecycle events work on both. The app name, version and build number come from the pubspec as built by Flutter, and the SDK keeps its state in the user's application data directory (`%APPDATA%\posthog` on Windows, `$XDG_DATA_HOME/posthog` or `~/.local/share/posthog` on Linux). If no application data directory is available, state stays in memory and is lost when the SDK closes or the app exits. Events include `$timezone` when the SDK can determine a local IANA time zone; on Windows, this uses the system ICU data on a best-effort basis. Session replay, surveys, logs, push notifications and native crash capture are not supported on Windows and Linux. +Add Windows and Linux support, set up with the same `Posthog().setup(config)` as the other platforms. Event and screen capture, exception capture (including autocapture of Flutter and Dart errors, and exception steps), identify, alias, groups, super properties, feature flags, bootstrap, opt-out and application lifecycle events work on both. App metadata comes from the executable's `ProductName` and `ProductVersion` on Windows and the bundled `version.json` on Linux. State is stored under `posthog/` in the application support directory returned by the official Windows/Linux `path_provider` implementations. If the directory is unavailable, state stays in memory and is lost when the SDK closes or the app exits. Events include `$timezone` when the SDK can determine a local IANA time zone; on Windows, this uses the system ICU data on a best-effort basis. Session replay, surveys, logs, push notifications and native crash capture are not supported on Windows and Linux. diff --git a/README.md b/README.md index a65a8c4b..350b4b54 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,15 @@ Desktop state is stored under `posthog/` in the application support directory returned by Flutter's Windows/Linux `path_provider` implementations. Keep the application's storage identity stable across releases: Windows uses the company/product metadata, and Linux uses the application ID, -with executable-name fallbacks -when that metadata is unavailable. +with executable-name fallbacks when that metadata is unavailable. On Linux, +`path_provider` also reuses an existing executable-name directory if the +application-ID directory does not exist. Await `Posthog().setup(config)` to finish initialization. If the application support directory is unavailable, desktop state stays in memory and is lost -when the SDK closes or the app exits. +when the SDK closes or the app exits. If an existing state file cannot be read, +that client keeps state changes in memory and leaves the file untouched. A new +client can try reading the file again. ## Questions? From 6380774ca805adb5e4f185deb27e5145e736ec47 Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Sat, 3 Oct 2026 00:19:25 +0300 Subject: [PATCH 11/22] =?UTF-8?q?=F0=9F=90=9B=20[fix]:=20keep=20storage=20?= =?UTF-8?q?contents=20out=20of=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- posthog_flutter/lib/src/core/file_storage.dart | 4 ++-- posthog_flutter/test/core/file_storage_test.dart | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/posthog_flutter/lib/src/core/file_storage.dart b/posthog_flutter/lib/src/core/file_storage.dart index 166344b8..4c3a7402 100644 --- a/posthog_flutter/lib/src/core/file_storage.dart +++ b/posthog_flutter/lib/src/core/file_storage.dart @@ -190,10 +190,10 @@ class FileStorage { try { return jsonDecode(utf8.decode(bytes)) as Map; - } catch (e) { + } catch (_) { // Corrupt content (torn write, foreign data) will not heal on retry, // so the store resets instead of staying degraded forever. - _resetLogger.warn('Resetting unreadable posthog store:', e); + _resetLogger.warn('Resetting corrupt PostHog store.'); return {}; } } diff --git a/posthog_flutter/test/core/file_storage_test.dart b/posthog_flutter/test/core/file_storage_test.dart index 7b0f3ea8..3aadbe1c 100644 --- a/posthog_flutter/test/core/file_storage_test.dart +++ b/posthog_flutter/test/core/file_storage_test.dart @@ -229,6 +229,22 @@ void main() { }, skip: chmodSkip); for (final debug in [true, false]) { + test('corrupt snapshot warnings omit stored values with debug: $debug', + () async { + final server = await LocalPostHogServer.start(); + final dir = tempDirectory(); + File('${dir.path}/posthog_data.json').writeAsStringSync( + '{"distinct_id":"private-user","props":{"email":"private@example.com"}', + ); + + final lines = printedLines(() => testClient(server, + config: testConfig(debug: debug), storage: FileStorage(dir.path))); + + expect(lines.where((line) => line.contains('Resetting')), hasLength(1)); + expect(lines.join('\n'), isNot(contains('private-user'))); + expect(lines.join('\n'), isNot(contains('private@example.com'))); + }); + test('a client with debug: $debug reports write failures accordingly', () async { final server = await LocalPostHogServer.start(); From 562e88164e816ebb9256511a5ba6d3e6d02a98b9 Mon Sep 17 00:00:00 2001 From: Snow Lukin Date: Sat, 3 Oct 2026 00:19:26 +0300 Subject: [PATCH 12/22] =?UTF-8?q?=F0=9F=93=9D=20[docs]:=20clarify=20corrup?= =?UTF-8?q?t=20storage=20recovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 350b4b54..224dcd36 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,10 @@ application-ID directory does not exist. Await `Posthog().setup(config)` to finish initialization. If the application support directory is unavailable, desktop state stays in memory and is lost -when the SDK closes or the app exits. If an existing state file cannot be read, -that client keeps state changes in memory and leaves the file untouched. A new -client can try reading the file again. +when the SDK closes or the app exits. If an I/O error prevents reading an +existing state file, that client keeps state changes in memory and leaves the +file untouched. A new client can try reading the file again. Corrupt file +contents reset the state to defaults; the next state write replaces the file. ## Questions? From 5ae540fd653fafc9c0a09aabd6fb58e69ef966c1 Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:19:56 +0300 Subject: [PATCH 13/22] =?UTF-8?q?=E2=9C=A8=20[feat]:=20send=20the=20queue?= =?UTF-8?q?=20on=20desktop=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close() now makes one attempt to send the queued events, bounded by two seconds, before it closes the client. A setup() that follows waits for that close, so the next client gets the storage lock and the queue on disk instead of falling back to memory. --- README.md | 6 +++ .../lib/src/posthog_flutter_desktop.dart | 26 +++++++++++-- .../test/posthog_flutter_desktop_test.dart | 38 ++++++++++++++++++- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 224dcd36..66559e1e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ existing state file, that client keeps state changes in memory and leaves the file untouched. A new client can try reading the file again. Corrupt file contents reset the state to defaults; the next state write replaces the file. +`Posthog().close()` makes one attempt to send the queued events and waits for it +for up to two seconds. Events it could not send stay on disk and go out with the +next client, or are lost when state is kept in memory. Closing the app window +does not call `close()`, but the SDK also sends the queue whenever the app +becomes inactive. + ## Questions? ### [Check out our community page.](https://posthog.com/posts) diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 948d4cee..98204a9b 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -55,6 +55,11 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { DesktopPostHog? _client; + /// The close() of the previous client. It keeps the storage directory + /// locked while it sends the queue, so the next client waits for it: + /// a client that finds the directory locked keeps its state in memory. + Future _previousClose = Future.value(); + _ClientScope? _clientScope; /// The configuration of the latest setup(), read by the Dart-side hooks: @@ -137,6 +142,7 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { Future _setup(PostHogConfig config, int generation) async { try { final appDirectory = await _appDirectory(); + await _previousClose; // A close() during directory lookup must not create another client. if (generation != _setupGeneration) return; _startClient(config, appDirectory); @@ -528,12 +534,26 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { _featureFlagsUnsubscribe?.call(); _featureFlagsUnsubscribe = null; - // Nothing is sent on close: queued events stay on disk and go out - // with the next client. - client.close(); + return _previousClose = _closeClient(client); }); } + /// How long close() waits for the queue to be sent. + static const _closeFlushTimeout = Duration(seconds: 2); + + /// Makes one bounded attempt to send the queue, then closes [client]. + /// Events left unsent stay on disk for the next client, or are lost when + /// the storage is in memory. Nothing is sent while PostHog asked to wait + /// (Retry-After). + static Future _closeClient(DesktopPostHog client) async { + try { + await client.flush().timeout(_closeFlushTimeout); + } catch (e) { + printIfDebug('[PostHog] Events left unsent on close: $e'); + } + client.close(); + } + @override Future getSessionId() => _guardWith('getSessionId', null, (client) => client.getSessionId()); diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index e3564bfc..7f54a2d2 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -1271,8 +1271,6 @@ void main() { await platform.setup(configFor(server)..flushAt = 20); await platform.capture(eventName: 'first client event'); - // The closed client leaves its queued event on disk, where the next - // client picks it up. final close = platform.close(); final setup = platform.setup(configFor(server)); final capture = platform.capture(eventName: 'second client event'); @@ -1282,6 +1280,42 @@ void main() { expect(server.eventNames, ['first client event', 'second client event']); }); + test('close sends the queued events', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)..flushAt = 20); + await platform.capture(eventName: 'queued event'); + + await platform.close(); + + expect(server.eventNames, ['queued event']); + }); + + test( + 'close gives up on a server that does not answer and leaves the ' + 'queue to the next client', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)..flushAt = 20); + await platform.capture(eventName: 'queued event'); + final unanswered = Completer(); + server.respond = (_) => unanswered.future; + + final close = platform.close(); + // Started before close completes: the next client must still find the + // queue on disk rather than keep its state in memory. + final setup = platform.setup(configFor(server)); + await close; + server.respond = (_) => const PostHogResponse(HttpStatus.ok); + await setup; + await platform.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + expect(server.eventNames, [ + 'queued event', // the request close gave up on + 'queued event', + 'sentinel event', + ]); + }); + test('an event whose callbacks finish after close reaches the next client', () async { final server = await LocalPostHogServer.start(); From 1c849693bc5d08027072d668b4eef5b83adf3ce6 Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:21:45 +0300 Subject: [PATCH 14/22] =?UTF-8?q?=F0=9F=90=9B=20[fix]:=20keep=20unawaited?= =?UTF-8?q?=20captures=20on=20desktop=20before=20identity=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without beforeSend callbacks the event was still awaited, so an identify(), reset() or opt-out made right after an unawaited capture, screen or captureException ran first, and the event was dropped as belonging to another user. Now it is captured synchronously, as on the other platforms. An event dropped after its callbacks is now logged in debug mode. --- .../lib/src/posthog_flutter_desktop.dart | 45 ++++++++++++------- .../test/posthog_flutter_desktop_test.dart | 28 ++++++++++++ 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 98204a9b..69f6c29a 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -90,10 +90,12 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { String distinctId, ) { final client = _clientFor(op); - if (client == null || - _clientScope != scope || + if (client == null) return null; + if (_clientScope != scope || client.optedOut || client.getDistinctId() != distinctId) { + printIfDebug('[PostHog] $op dropped: the project, the user or the ' + 'consent changed while beforeSend was running.'); return null; } return client; @@ -248,12 +250,14 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final distinctId = client.getDistinctId(); final exceptionSteps = _exceptionSteps; - final processed = await _runBeforeSend( + final hooked = _runBeforeSend( eventName, properties, userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, ); + final processed = + hooked is Future ? await hooked : hooked; if (processed == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); return; @@ -278,13 +282,15 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { final distinctId = client.getDistinctId(); final exceptionSteps = _exceptionSteps; - final processed = await _runBeforeSend( + final hooked = _runBeforeSend( PostHogEventName.screen, { ...?properties, PostHogPropertyName.screenName: screenName, }, ); + final processed = + hooked is Future ? await hooked : hooked; if (processed == null) { printIfDebug( '[PostHog] Screen event dropped by beforeSend: $screenName'); @@ -492,10 +498,12 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { inAppByDefault: _config?.errorTrackingConfig.inAppByDefault ?? true, ); - final processed = await _runBeforeSend( + final hooked = _runBeforeSend( PostHogEventName.exception, exceptionProps.cast(), ); + final processed = + hooked is Future ? await hooked : hooked; if (processed == null) { printIfDebug( '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', @@ -588,21 +596,26 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { /// /// Returns the possibly modified event, or null if any callback drops it /// or throws. - Future _runBeforeSend( + /// + /// Without callbacks the event is returned synchronously and the caller + /// must not await it: an identify(), reset() or opt-out made right after + /// an unawaited capture would otherwise run first, and the event would be + /// dropped as belonging to another user. + FutureOr _runBeforeSend( String eventName, Map? properties, { Map? userProperties, Map? userPropertiesSetOnce, - }) => - applyBeforeSend( - _config?.beforeSend ?? const [], - PostHogEvent( - event: eventName, - properties: properties, - userProperties: userProperties, - userPropertiesSetOnce: userPropertiesSetOnce, - ), - ); + }) { + final event = PostHogEvent( + event: eventName, + properties: properties, + userProperties: userProperties, + userPropertiesSetOnce: userPropertiesSetOnce, + ); + final callbacks = _config?.beforeSend ?? const []; + return callbacks.isEmpty ? event : applyBeforeSend(callbacks, event); + } void _captureProcessed( DesktopPostHog client, diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 7f54a2d2..7404cc65 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -1422,6 +1422,34 @@ void main() { }); } + final identityChanges = + Function(PosthogFlutterDesktop)>{ + 'identify': (platform) => platform.identify(userId: 'next-user'), + 'reset': (platform) => platform.reset(), + }; + + for (final identityChange in identityChanges.entries) { + for (final entry in pendingCalls.entries) { + test( + '${entry.key} without hooks is sent before an unawaited ' + '${identityChange.key}', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)); + final distinctId = await platform.getDistinctId(); + + final call = entry.value.call(platform); + final change = identityChange.value(platform); + await Future.wait([call, change]); + await platform.capture(eventName: 'sentinel event'); + + await server.waitForEvent('sentinel event'); + final event = server.events + .singleWhere((event) => event['event'] == entry.value.eventName); + expect(event['distinct_id'], distinctId); + }); + } + } + for (final identityChange in ['identify', 'reset']) { for (final entry in pendingCalls.entries) { test('${entry.key} pending in a hook does not cross $identityChange', From 35ba860d27dcb3270e89ec85edbd01b96ced5e35 Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:22:37 +0300 Subject: [PATCH 15/22] =?UTF-8?q?=E2=9C=A8=20[feat]:=20read=20the=20Linux?= =?UTF-8?q?=20time=20zone=20from=20/etc/timezone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where /etc/localtime is a copy rather than a link into the zoneinfo directory, as in many containers, $timezone was left out. Debian-based systems still name the zone in /etc/timezone, which is now read as a fallback. --- .../lib/src/posthog_desktop_time_zone.dart | 20 ++++++++++++++++++- .../test/posthog_desktop_time_zone_test.dart | 13 ++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/posthog_flutter/lib/src/posthog_desktop_time_zone.dart b/posthog_flutter/lib/src/posthog_desktop_time_zone.dart index 330bbb0c..a7ca8e15 100644 --- a/posthog_flutter/lib/src/posthog_desktop_time_zone.dart +++ b/posthog_flutter/lib/src/posthog_desktop_time_zone.dart @@ -18,15 +18,33 @@ class DesktopTimeZone { return null; } } + return _readLinux(); + } + + /// `/etc/localtime` is usually a link into the zoneinfo directory. Where + /// it is a copy, as in many containers, Debian-based systems still name + /// the zone in `/etc/timezone`. + static String? _readLinux() { try { - return fromZoneInfoPath( + final zone = fromZoneInfoPath( File('/etc/localtime').resolveSymbolicLinksSync(), ); + if (zone != null) return zone; + } on FileSystemException { + // No /etc/localtime: /etc/timezone may still name the zone. + } + try { + return fromTimezoneFile(File('/etc/timezone').readAsStringSync()); } on FileSystemException { return null; } } + /// The zone named by the contents of an `/etc/timezone` file, such as + /// `Europe/Berlin` followed by a line break. + static String? fromTimezoneFile(String contents) => + _zoneName(contents.trim()); + /// The zone a `TZ` value names: a zone name, optionally after a `:`, or /// the path of a file in a zoneinfo directory. /// diff --git a/posthog_flutter/test/posthog_desktop_time_zone_test.dart b/posthog_flutter/test/posthog_desktop_time_zone_test.dart index ca0be7b4..e2c8bc8f 100644 --- a/posthog_flutter/test/posthog_desktop_time_zone_test.dart +++ b/posthog_flutter/test/posthog_desktop_time_zone_test.dart @@ -61,6 +61,19 @@ void main() { }); }); + group('DesktopTimeZone.fromTimezoneFile', () { + test('reads the zone the file names', () { + expect( + DesktopTimeZone.fromTimezoneFile('Europe/Berlin\n'), 'Europe/Berlin'); + expect(DesktopTimeZone.fromTimezoneFile(' Etc/UTC '), 'Etc/UTC'); + }); + + test('reads no zone from an empty file or a POSIX rule', () { + expect(DesktopTimeZone.fromTimezoneFile(''), isNull); + expect(DesktopTimeZone.fromTimezoneFile('JST-9\n'), isNull); + }); + }); + group('DesktopTimeZone.fromZoneInfoPath', () { test('reads the zone after the zoneinfo directory', () { expect( From 70a66d2827be8e6712c1fbc8bd6943575a369d63 Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:23:38 +0300 Subject: [PATCH 16/22] =?UTF-8?q?=F0=9F=93=9D=20[docs]:=20document=20close?= =?UTF-8?q?()=20on=20Windows=20and=20Linux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public close() docs now say that desktop sends the queue first, bounded by two seconds, and what happens to the events left unsent. The core client docs no longer promise that queued events survive close() with an in-memory storage. --- posthog_flutter/lib/src/core/posthog_core_stateless.dart | 3 ++- posthog_flutter/lib/src/posthog.dart | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/posthog_flutter/lib/src/core/posthog_core_stateless.dart b/posthog_flutter/lib/src/core/posthog_core_stateless.dart index 03cd047d..d9dbe26b 100644 --- a/posthog_flutter/lib/src/core/posthog_core_stateless.dart +++ b/posthog_flutter/lib/src/core/posthog_core_stateless.dart @@ -653,7 +653,8 @@ abstract class PostHogCoreStateless { /// Closes the client without sending anything: stops the periodic flush, /// aborts the requests in flight, ignores later calls and releases the - /// storage. Queued events stay stored for the next client. + /// storage. Queued events stay on disk for the next client; an in-memory + /// storage loses them. void close() { _closed = true; _clearFlushTimer(); diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 3bfe1851..7861901b 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -869,6 +869,11 @@ class Posthog { /// /// Returns a [Future] that completes when platform resources have been closed. /// + /// **Windows/Linux:** first makes one attempt to send the queued events and + /// waits for it for up to two seconds. Events left unsent go out with the + /// next [setup], or are lost when the application support directory is + /// unavailable and state is kept in memory. + /// /// **Note:** After calling `close()`, surveys will not be rendered until the /// SDK is re-initialized and the next navigation event occurs. Future close() { From d1ca23a84474db15105f60ab76c4ab147526b5d5 Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:31:33 +0300 Subject: [PATCH 17/22] =?UTF-8?q?=F0=9F=90=9B=20[fix]:=20deliver=20events?= =?UTF-8?q?=20whose=20callbacks=20finish=20during=20desktop=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An event whose async beforeSend callbacks finished after close() and before the next setup() completed found no client and was dropped, although it belongs to the same project and user. It now waits for the setup in progress. A repeated close() also waits until the queue is sent instead of returning at once. --- .../lib/src/posthog_flutter_desktop.dart | 31 +++++++--- .../test/posthog_flutter_desktop_test.dart | 56 +++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 69f6c29a..4e5be66d 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -256,8 +256,9 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, ); - final processed = - hooked is Future ? await hooked : hooked; + final processed = hooked is Future + ? await _afterCallbacks(hooked) + : hooked; if (processed == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); return; @@ -289,8 +290,9 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { PostHogPropertyName.screenName: screenName, }, ); - final processed = - hooked is Future ? await hooked : hooked; + final processed = hooked is Future + ? await _afterCallbacks(hooked) + : hooked; if (processed == null) { printIfDebug( '[PostHog] Screen event dropped by beforeSend: $screenName'); @@ -502,8 +504,9 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { PostHogEventName.exception, exceptionProps.cast(), ); - final processed = - hooked is Future ? await hooked : hooked; + final processed = hooked is Future + ? await _afterCallbacks(hooked) + : hooked; if (processed == null) { printIfDebug( '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', @@ -530,10 +533,10 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { }); @override - Future close() { + Future close() async { _setupGeneration++; _setupFuture = null; - return _guard('close', (client) { + await _guard('close', (client) { _client = null; _clientScope = null; _exceptionSteps = null; @@ -544,6 +547,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return _previousClose = _closeClient(client); }); + // A repeated close() also waits until the queue is sent. + await _previousClose; } /// How long close() waits for the queue to be sent. @@ -617,6 +622,16 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { return callbacks.isEmpty ? event : applyBeforeSend(callbacks, event); } + /// Awaits the beforeSend callbacks, then a setup() in progress: an event + /// whose callbacks finish while the previous client is still closing + /// goes to the next client rather than finding none. + Future _afterCallbacks(Future hooked) async { + final event = await hooked; + final setup = _setupFuture; + if (_client == null && setup != null) await setup; + return event; + } + void _captureProcessed( DesktopPostHog client, PostHogEvent event, diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 7404cc65..c622d407 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -1280,6 +1280,62 @@ void main() { expect(server.eventNames, ['first client event', 'second client event']); }); + test('a repeated close waits until the queue is sent', () async { + final server = await LocalPostHogServer.start(); + final platform = await setUpPlatform(configFor(server)..flushAt = 20); + await platform.capture(eventName: 'queued event'); + final batch = Completer(); + server.respond = (_) => batch.future; + + final firstClose = platform.close(); + var repeatedCloseDone = false; + final repeatedClose = + platform.close().then((_) => repeatedCloseDone = true); + await Future.delayed(const Duration(milliseconds: 50)); + expect(repeatedCloseDone, isFalse); + + batch.complete(const PostHogResponse(HttpStatus.ok)); + await Future.wait([firstClose, repeatedClose]); + }); + + test( + 'an event whose callbacks finish while close sends the queue reaches ' + 'the next client', () async { + final server = await LocalPostHogServer.start(); + final releaseHook = Completer(); + final config = configFor(server) + ..flushAt = 20 + ..beforeSend = [ + (event) async { + if (event.event == 'slow event') await releaseHook.future; + return event; + }, + ]; + final platform = await setUpPlatform(config); + await platform.capture(eventName: 'queued event'); + final batch = Completer(); + final batchReceived = Completer(); + server.respond = (_) { + if (batchReceived.isCompleted) { + return const PostHogResponse(HttpStatus.ok); + } + batchReceived.complete(); + return batch.future; + }; + + final slowCapture = platform.capture(eventName: 'slow event'); + final close = platform.close(); + final setup = platform.setup(config); + await batchReceived.future; + releaseHook.complete(); + await Future.delayed(const Duration(milliseconds: 50)); + batch.complete(const PostHogResponse(HttpStatus.ok)); + await Future.wait([slowCapture, close, setup]); + await platform.flush(); + + expect(server.eventNames, ['queued event', 'slow event']); + }); + test('close sends the queued events', () async { final server = await LocalPostHogServer.start(); final platform = await setUpPlatform(configFor(server)..flushAt = 20); From 523f0e7b215dce409d35941a6f7b5a3335d717af Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:36:04 +0300 Subject: [PATCH 18/22] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20[refactor]:=20wait?= =?UTF-8?q?=20for=20setup=20inside=20the=20desktop=20beforeSend=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three capture paths no longer repeat the wait for a setup in progress: the helper that applies the callbacks does it, and the call sites only await when callbacks ran. --- .../lib/src/posthog_flutter_desktop.dart | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 4e5be66d..31a24a02 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -256,9 +256,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { userProperties: userProperties, userPropertiesSetOnce: userPropertiesSetOnce, ); - final processed = hooked is Future - ? await _afterCallbacks(hooked) - : hooked; + final processed = + hooked is Future ? await hooked : hooked; if (processed == null) { printIfDebug('[PostHog] Event dropped by beforeSend: $eventName'); return; @@ -290,9 +289,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { PostHogPropertyName.screenName: screenName, }, ); - final processed = hooked is Future - ? await _afterCallbacks(hooked) - : hooked; + final processed = + hooked is Future ? await hooked : hooked; if (processed == null) { printIfDebug( '[PostHog] Screen event dropped by beforeSend: $screenName'); @@ -504,9 +502,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { PostHogEventName.exception, exceptionProps.cast(), ); - final processed = hooked is Future - ? await _afterCallbacks(hooked) - : hooked; + final processed = + hooked is Future ? await hooked : hooked; if (processed == null) { printIfDebug( '[PostHog] Exception event dropped by beforeSend: ${error.runtimeType}', @@ -619,17 +616,20 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { userPropertiesSetOnce: userPropertiesSetOnce, ); final callbacks = _config?.beforeSend ?? const []; - return callbacks.isEmpty ? event : applyBeforeSend(callbacks, event); + return callbacks.isEmpty ? event : _applyCallbacks(callbacks, event); } - /// Awaits the beforeSend callbacks, then a setup() in progress: an event + /// Applies [callbacks], then waits for a setup() in progress: an event /// whose callbacks finish while the previous client is still closing /// goes to the next client rather than finding none. - Future _afterCallbacks(Future hooked) async { - final event = await hooked; + Future _applyCallbacks( + List callbacks, + PostHogEvent event, + ) async { + final processed = await applyBeforeSend(callbacks, event); final setup = _setupFuture; if (_client == null && setup != null) await setup; - return event; + return processed; } void _captureProcessed( From c27c586bb9cc27d4c491ecf30965975d6d36767f Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:38:26 +0300 Subject: [PATCH 19/22] =?UTF-8?q?=F0=9F=93=9D=20[docs]:=20explain=20the=20?= =?UTF-8?q?legacy=20flags=20response=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostHog always answers /flags/?v=2 with the flags key. The v1 fallback is kept for servers that ignore v=2, matching posthog-js, iOS, Android and Python, and now says so. --- posthog_flutter/lib/src/core/feature_flag_utils.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/posthog_flutter/lib/src/core/feature_flag_utils.dart b/posthog_flutter/lib/src/core/feature_flag_utils.dart index 315340ed..210f0770 100644 --- a/posthog_flutter/lib/src/core/feature_flag_utils.dart +++ b/posthog_flutter/lib/src/core/feature_flag_utils.dart @@ -26,6 +26,8 @@ PostHogFlagsResponse parseFlagsResponse( ); } +/// The v1 shape, without `flags`. PostHog answers `/flags/?v=2` with `flags`; +/// this keeps servers that ignore `v=2` working, as the other PostHog SDKs do. Map _parseLegacyFlags( Map response, { required void Function(String key, Object error) onMalformedFlag, From b8e20eba382d8fcdb91f7d06b18eeb00916431e5 Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:17:48 +0300 Subject: [PATCH 20/22] =?UTF-8?q?=E2=9C=85=20[test]:=20close=20the=20deskt?= =?UTF-8?q?op=20platform=20before=20deleting=20its=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tear-downs run in reverse order, so a directory created after the platform was deleted while the platform still held its lock file open. Windows refuses that deletion, which failed two setup tests there. The directory is now created first. --- posthog_flutter/test/posthog_flutter_desktop_test.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index c622d407..e3bbb6d8 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -15,6 +15,9 @@ import 'posthog_api_fake.dart'; // HttpClient with a stub that answers 400 to everything, and these tests // exercise the real HTTP path against a local server. void main() { + /// Create it before the platform that uses it: tear-downs run in reverse + /// order, and Windows cannot delete a directory whose lock file the + /// platform still holds open. Directory createAppDirectory() { final dir = Directory.systemTemp.createTempSync('posthog_flutter_desktop'); addTearDown(() => dir.deleteSync(recursive: true)); @@ -1076,13 +1079,14 @@ void main() { test('disable during storage lookup applies before subsequent events', () async { final server = await LocalPostHogServer.start(); + final directory = createAppDirectory(); final release = Completer(); final platform = createPlatform(resolveAppDirectory: () => release.future); final setup = platform.setup(configFor(server)); final disable = platform.disable(); final capture = platform.capture(eventName: 'disabled event'); - release.complete(createAppDirectory().path); + release.complete(directory.path); await Future.wait([setup, disable, capture]); expect(await platform.isOptOut(), isTrue); await platform.enable(); @@ -1117,6 +1121,7 @@ void main() { () async { final server = await LocalPostHogServer.start(); final otherServer = await LocalPostHogServer.start(); + final directory = createAppDirectory(); final release = Completer(); var lookups = 0; final platform = createPlatform(resolveAppDirectory: () { @@ -1129,7 +1134,7 @@ void main() { ..beforeSend = [ (event) => event..properties = {'hook': 'latest'} ]); - release.complete(createAppDirectory().path); + release.complete(directory.path); await Future.wait([first, second]); await platform.capture(eventName: 'event'); final event = await server.waitForEvent('event'); From daa5b8b5f6afc8e06734854de428c5890b810c8a Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:11:31 +0300 Subject: [PATCH 21/22] =?UTF-8?q?=E2=9C=85=20[test]:=20run=20the=20desktop?= =?UTF-8?q?=20SDK=20inside=20the=20example=20app=20on=20Windows=20and=20Li?= =?UTF-8?q?nux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 10 + example/integration_test/desktop_test.dart | 227 +++++++++++++++++++++ example/pubspec.yaml | 4 + pubspec.lock | 39 ++++ 4 files changed, 280 insertions(+) create mode 100644 example/integration_test/desktop_test.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb791988..4c1de2ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,6 +224,11 @@ jobs: working-directory: ./posthog_flutter run: flutter test --dart-define=POSTHOG_TEST_EXECUTABLE=../example/build/windows/x64/runner/Release/posthog_flutter_example.exe test/posthog_windows_version_resource_test.dart + - name: Integration test on Windows + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + working-directory: ./example + run: flutter test integration_test/desktop_test.dart -d windows + test-build-linux: needs: detect-markdown-only name: Linux test and build @@ -265,6 +270,11 @@ jobs: working-directory: ./example run: flutter build linux --release + - name: Integration test on Linux + if: needs.detect-markdown-only.outputs.markdown_only != 'true' + working-directory: ./example + run: xvfb-run -a flutter test integration_test/desktop_test.dart -d linux + publish-dry-run: needs: detect-markdown-only name: Pub publish dry run diff --git a/example/integration_test/desktop_test.dart b/example/integration_test/desktop_test.dart new file mode 100644 index 00000000..e4b3c8b9 --- /dev/null +++ b/example/integration_test/desktop_test.dart @@ -0,0 +1,227 @@ +@TestOn('windows || linux') +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +// ignore: implementation_imports +import 'package:posthog_flutter/src/posthog_desktop_storage.dart'; +// ignore: implementation_imports +import 'package:posthog_flutter/src/posthog_flutter_desktop.dart'; +// ignore: implementation_imports +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; + +/// Runs the Windows and Linux implementation inside the built example app: +/// the plugin registrant, the application support directory, the app +/// metadata of the build and the platform HTTP stack are the real ones. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + var projectCount = 0; + String newProjectToken() => + 'e2e_${DateTime.now().microsecondsSinceEpoch}_${projectCount++}'; + + Future<_FakePostHog> startServer() async { + final server = await _FakePostHog.start(); + addTearDown(server.close); + return server; + } + + Future setUpPostHog( + _FakePostHog server, + String projectToken, { + int flushAt = 1, + }) async { + final config = PostHogConfig(projectToken) + ..host = server.url + ..flushAt = flushAt + ..debug = true; + await Posthog().setup(config); + addTearDown(Posthog().close); + } + + testWidgets('the plugin registrant installs the desktop implementation', ( + _, + ) async { + expect( + PosthogFlutterPlatformInterface.instance, + isA(), + ); + }); + + testWidgets( + 'events carry the app and OS context and the state is stored in the ' + 'application support directory', + (_) async { + final server = await startServer(); + final projectToken = newProjectToken(); + await setUpPostHog(server, projectToken); + + await Posthog().capture( + eventName: 'e2e event', + properties: {'source': 'integration test'}, + ); + + final event = await server.waitForEvent('e2e event'); + final properties = event['properties'] as Map; + expect(properties['source'], 'integration test'); + expect(properties[r'$lib'], 'posthog-flutter'); + expect(properties[r'$os_name'], Platform.isWindows ? 'Windows' : 'Linux'); + expect(properties[r'$os_version'], allOf(isA(), isNotEmpty)); + expect(properties[r'$device_type'], 'Desktop'); + expect(properties[r'$app_name'], 'posthog_flutter_example'); + expect(properties[r'$app_version'], '1.0.0'); + expect(properties[r'$app_build'], 1); + + final appDirectory = await DesktopStorage.appDirectory(); + expect(appDirectory, isNotNull); + expect( + Directory( + DesktopStorage.projectDirectory(appDirectory!, projectToken), + ).existsSync(), + isTrue, + ); + }, + ); + + testWidgets('flag requests carry the app and OS person properties', ( + _, + ) async { + final server = await startServer(); + await setUpPostHog(server, newProjectToken()); + + await Posthog().reloadFeatureFlags(); + + final request = await server.waitForFlagsRequest(); + final personProperties = + request['person_properties'] as Map; + expect(personProperties[r'$app_version'], '1.0.0'); + expect(personProperties[r'$app_build'], 1); + expect( + personProperties[r'$os_name'], + Platform.isWindows ? 'Windows' : 'Linux', + ); + expect(personProperties[r'$os_version'], isNotNull); + expect(personProperties[r'$device_type'], 'Desktop'); + expect(personProperties[r'$lib'], 'posthog-flutter'); + expect(await Posthog().getFeatureFlag('e2e-flag'), 'test-variant'); + }); + + testWidgets('close sends the queued events', (_) async { + final server = await startServer(); + await setUpPostHog(server, newProjectToken(), flushAt: 20); + await Posthog().capture(eventName: 'queued event'); + + await Posthog().close(); + + expect(server.eventNames, contains('queued event')); + }); + + testWidgets('a queued event survives the client and goes out with the next', ( + _, + ) async { + final server = await startServer(); + final projectToken = newProjectToken(); + server.batchStatus = HttpStatus.serviceUnavailable; + await setUpPostHog(server, projectToken, flushAt: 20); + await Posthog().capture(eventName: 'survivor event'); + await Posthog().close(); + expect(server.eventNames, isNot(contains('survivor event'))); + + server.batchStatus = HttpStatus.ok; + await setUpPostHog(server, projectToken, flushAt: 20); + await Posthog().flush(); + + expect(server.eventNames, contains('survivor event')); + }); +} + +/// A PostHog API on the loopback interface that records the batches and +/// answers every flag request with one variant flag. +class _FakePostHog { + _FakePostHog._(this._server); + + final HttpServer _server; + final _batches = >[]; + final _flagsRequests = >[]; + + /// The status of the answers to batches. + int batchStatus = HttpStatus.ok; + + String get url => 'http://127.0.0.1:${_server.port}'; + + static Future<_FakePostHog> start() async { + final server = _FakePostHog._( + await HttpServer.bind(InternetAddress.loopbackIPv4, 0), + ); + server._server.listen(server._serve); + return server; + } + + Future close() => _server.close(force: true); + + List> get events => [ + for (final batch in _batches) + for (final event in batch['batch'] as List) + event as Map, + ]; + + List get eventNames => [for (final e in events) e['event']]; + + Future> waitForEvent(String name) async { + await _waitFor(() => eventNames.contains(name)); + return events.firstWhere((event) => event['event'] == name); + } + + Future> waitForFlagsRequest() async { + await _waitFor(() => _flagsRequests.isNotEmpty); + return _flagsRequests.first; + } + + Future _waitFor(bool Function() isReady) async { + final deadline = DateTime.now().add(const Duration(seconds: 15)); + while (!isReady()) { + if (DateTime.now().isAfter(deadline)) { + fail('PostHog received ${eventNames.length} events: $eventNames'); + } + await Future.delayed(const Duration(milliseconds: 50)); + } + } + + Future _serve(HttpRequest request) async { + final bytes = await request.fold>([], (all, c) => all..addAll(c)); + final gzipped = + request.headers.value(HttpHeaders.contentEncodingHeader) == 'gzip'; + final body = + jsonDecode(utf8.decode(gzipped ? gzip.decode(bytes) : bytes)) + as Map; + + final response = request.response; + switch (request.uri.path) { + case '/batch/': + response.statusCode = batchStatus; + if (batchStatus == HttpStatus.ok) _batches.add(body); + case '/flags/': + _flagsRequests.add(body); + response.headers.contentType = ContentType.json; + response.write( + jsonEncode({ + 'flags': { + 'e2e-flag': { + 'key': 'e2e-flag', + 'enabled': true, + 'variant': 'test-variant', + 'metadata': {'id': 1, 'version': 1}, + }, + }, + }), + ); + default: + response.statusCode = HttpStatus.notFound; + } + await response.close(); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 8d3d0d89..a193930e 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -36,6 +36,10 @@ dependencies: google_maps_flutter: ^2.18.1 dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/pubspec.lock b/pubspec.lock index 0864013c..1d0a2b4e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -182,6 +182,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: transitive description: @@ -216,6 +221,11 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -280,6 +290,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.6" + integration_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" json_annotation: dependency: transitive description: @@ -432,6 +447,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" pub_semver: dependency: transitive description: @@ -533,6 +556,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -605,6 +636,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" webview_flutter: dependency: transitive description: From 266a0f5ccce4548c45f4459cb14474b8b5be3cda Mon Sep 17 00:00:00 2001 From: StarProxima <34741787+StarProxima@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:41:36 +0300 Subject: [PATCH 22/22] =?UTF-8?q?=F0=9F=90=9B=20[fix]:=20drop=20group=20fl?= =?UTF-8?q?ag=20properties=20when=20the=20group=20key=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit group('company', 'free-company') after group('company', 'paid-company', groupProperties: {'plan': 'pro'}) kept the paid company's properties, so the next /flags request evaluated free-company with plan = pro. A new key for a group type now clears the flag properties stored for that type before the new ones are applied, as posthog-js does. --- .../lib/src/core/posthog_core.dart | 8 ++++-- .../test/core/feature_flags_test.dart | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index b33ada29..f320b242 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -432,8 +432,9 @@ abstract class PostHogCore extends PostHogCoreStateless { /// Associates the current user with a group for all later events, and /// sends a `$groupidentify` event with the optional [groupProperties]. /// - /// The group properties also feed feature flag evaluation. Flags are - /// reloaded when the group key changes. A call with an empty [groupType] + /// The group properties also feed feature flag evaluation. When the group + /// key changes, the properties stored for the previous key are dropped and + /// flags are reloaded. A call with an empty [groupType] /// or [groupKey] is ignored. void group( String groupType, @@ -449,6 +450,9 @@ abstract class PostHogCore extends PostHogCoreStateless { if (_ignoredWhileOptedOut('posthog.group')) return; if (!_requirePersonProcessing('posthog.group')) return; + if (_getGroups()[groupType] != groupKey) { + resetGroupPropertiesForFlags(groupType: groupType); + } // Stored before the group is registered, so the flags reload a new // group key triggers already evaluates with them. if (groupProperties != null && groupProperties.isNotEmpty) { diff --git a/posthog_flutter/test/core/feature_flags_test.dart b/posthog_flutter/test/core/feature_flags_test.dart index 4dce5aa0..86012626 100644 --- a/posthog_flutter/test/core/feature_flags_test.dart +++ b/posthog_flutter/test/core/feature_flags_test.dart @@ -46,6 +46,31 @@ void main() { }); }); + test('a new group key drops the group properties of the previous key', + () async { + final client = testClient(server); + + client.group('company', 'paid-company', groupProperties: {'plan': 'pro'}); + client.group('company', 'free-company'); + await client.reloadFeatureFlagsAsync(); + + final body = _flagsRequestBody(server); + expect(body['groups'], {'company': 'free-company'}); + expect(body['group_properties'], isEmpty); + }); + + test('the same group key keeps its group properties', () async { + final client = testClient(server); + + client.group('company', 'paid-company', groupProperties: {'plan': 'pro'}); + client.group('company', 'paid-company'); + await client.reloadFeatureFlagsAsync(); + + expect(_flagsRequestBody(server)['group_properties'], { + 'company': {'plan': 'pro'}, + }); + }); + test('an empty response clears stale flags and the recorded error', () async { final storage = tempStorage();