From 9cac01055211a8bf9ccc16859a2b6f449ed206b6 Mon Sep 17 00:00:00 2001 From: Sander Verkuil Date: Fri, 20 Oct 2023 18:55:52 +0200 Subject: [PATCH] chore: Started configuring the bundle This commit starts with configuring the bundle, writing the readme, setting up the code analyzers, writing the first simple test and event listener. --- .editorconfig | 20 +++ .env.test | 0 .gitattributes | 8 + .github/ISSUE_TEMPLATE/bug.yml | 39 +++++ .github/ISSUE_TEMPLATE/config.yml | 2 + .github/ISSUE_TEMPLATE/feature.yml | 25 ++++ .github/workflows/main.yml | 26 ++++ .github/workflows/static-analysis.yaml | 64 ++++++++ .github/workflows/tests.yaml | 122 +++++++++++++++ .gitignore | 10 ++ .php-cs-fixer.dist.php | 35 +++++ AUTHORS | 1 + CHANGELOG.md | 0 CONTRIBUTING.md | 90 +++++++++++ LICENSE | 21 +++ MAKEFILE | 21 +++ README.md | 96 ++++++++++++ codecov.yml | 1 + composer.json | 80 ++++++++++ config/schema/posthog-1.0.xsd | 13 ++ config/services.xml | 31 ++++ phpstan.neon | 12 ++ phpunit.xml | 11 ++ psalm.xml | 16 ++ src/Adapter/PostHogAdapter.php | 85 +++++++++++ src/Client/ClientBuilder.php | 55 +++++++ src/Client/ClientBuilderInterface.php | 9 ++ src/Client/Options/Options.php | 30 ++++ src/Command/PostHogTestCommand.php | 33 +++++ src/Context.php | 12 ++ .../Compiler/AddLoginListenerTagPass.php | 29 ++++ src/DependencyInjection/Configuration.php | 26 ++++ src/DependencyInjection/PostHogExtension.php | 57 +++++++ src/EventListener/LoginListener.php | 140 ++++++++++++++++++ src/EventListener/RequestListener.php | 45 ++++++ src/Exception/NotInitializedException.php | 15 ++ src/PostHogBundle.php | 59 ++++++++ src/functions.php | 18 +++ tests/BaseTestCase.php | 11 ++ tests/Command/TestPostHogCommandTest.php | 59 ++++++++ .../End2End/App/Controller/MainController.php | 28 ++++ tests/End2End/App/Kernel.php | 34 +++++ tests/End2End/App/config.yml | 17 +++ tests/End2End/App/deprecations_for_5.yml | 3 + tests/End2End/App/deprecations_for_6.yml | 2 + tests/End2End/App/routing.yml | 4 + tests/End2End/End2EndTest.php | 43 ++++++ .../Fixtures/UserWithIdentifierStub.php | 46 ++++++ tests/EventListener/LoginListenerTest.php | 132 +++++++++++++++++ tests/bootstrap.php | 5 + 50 files changed, 1741 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.test create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/static-analysis.yaml create mode 100644 .github/workflows/tests.yaml create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 AUTHORS create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 MAKEFILE create mode 100644 README.md create mode 100644 codecov.yml create mode 100644 composer.json create mode 100644 config/schema/posthog-1.0.xsd create mode 100644 config/services.xml create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 psalm.xml create mode 100644 src/Adapter/PostHogAdapter.php create mode 100644 src/Client/ClientBuilder.php create mode 100644 src/Client/ClientBuilderInterface.php create mode 100644 src/Client/Options/Options.php create mode 100644 src/Command/PostHogTestCommand.php create mode 100644 src/Context.php create mode 100644 src/DependencyInjection/Compiler/AddLoginListenerTagPass.php create mode 100644 src/DependencyInjection/Configuration.php create mode 100644 src/DependencyInjection/PostHogExtension.php create mode 100644 src/EventListener/LoginListener.php create mode 100644 src/EventListener/RequestListener.php create mode 100644 src/Exception/NotInitializedException.php create mode 100644 src/PostHogBundle.php create mode 100644 src/functions.php create mode 100644 tests/BaseTestCase.php create mode 100644 tests/Command/TestPostHogCommandTest.php create mode 100644 tests/End2End/App/Controller/MainController.php create mode 100644 tests/End2End/App/Kernel.php create mode 100644 tests/End2End/App/config.yml create mode 100644 tests/End2End/App/deprecations_for_5.yml create mode 100644 tests/End2End/App/deprecations_for_6.yml create mode 100644 tests/End2End/App/routing.yml create mode 100644 tests/End2End/End2EndTest.php create mode 100644 tests/EventListener/Fixtures/UserWithIdentifierStub.php create mode 100644 tests/EventListener/LoginListenerTest.php create mode 100644 tests/bootstrap.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..437116f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +; top-most EditorConfig file +root = true + +; Unix-style newlines +[*] +charset = utf-8 +end_of_line = LF +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.md] +max_line_length = 80 + +[*.{yml, yaml}] +indent_size = 2 + +[COMMIT_EDITMSG] +max_line_length = 0 \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..e69de29 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..10f4ceb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +tests/ export-ignore +var/ export-ignore +.* export-ignore +Makefile export-ignore +phpunit.xml export-ignore +codecov.yml export-ignore +phpstan.neon export-ignore +phpstan-baseline.neon export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..43ee9f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,39 @@ +name: 🐞 Bug Report +description: Tell us about something that's not working the way we (probably) intend. +body: + - type: input + id: version + attributes: + label: SDK version + description: Which SDK version do you use? + placeholder: e.g. 3.0.8 + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: How can we see what you're seeing? Specific is terrific. + placeholder: |- + 1. What + 2. you + 3. did. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected result + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual result + description: Logs? Screenshots? Yes, please. + validations: + required: true + - type: markdown + attributes: + value: |- + ## Thanks 🙏 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b92c70c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: true + diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..a4ece28 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,25 @@ +name: 💡 Feature Request +description: Create a feature request for this SDK. +labels: 'enhancement' +body: + - type: markdown + attributes: + value: Thanks for taking the time to file a feature request! Please fill out this form as completely as possible. + - type: textarea + id: problem + attributes: + label: Problem Statement + description: A clear and concise description of what you want and what your use case is. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Solution Brainstorm + description: We know you have bright ideas to share ... share away, friend. + validations: + required: true + - type: markdown + attributes: + value: |- + ## Thanks 🙏 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..fe0fbc5 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,26 @@ +name: "Lint PR" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: + pull-requests: read + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + didn't match the configured pattern. Please ensure that the subject + doesn't start with an uppercase character. diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml new file mode 100644 index 0000000..2e7a919 --- /dev/null +++ b/.github/workflows/static-analysis.yaml @@ -0,0 +1,64 @@ +name: Code style and static analysis + +on: + pull_request: + push: + branches: + - main + - development + - release/** + +jobs: + php-cs-fixer: + name: PHP-CS-Fixer + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + + - name: Install dependencies + run: composer update --no-progress --no-interaction --prefer-dist + + - name: Run script + run: composer phpcs + + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + + - name: Install dependencies + run: composer update --no-progress --no-interaction --prefer-dist + + - name: Run script + run: composer phpstan + + psalm: + name: Psalm + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + + - name: Install dependencies + run: composer update --no-progress --no-interaction --prefer-dist + + - name: Run script + run: composer psalm -- --php-version=8.0 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..c8614e7 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,122 @@ +name: Continuous Integration + +on: + pull_request: null + push: + branches: + - main + - development + - release/** + +jobs: + tests: + name: Tests + runs-on: ubuntu-latest + env: + SYMFONY_REQUIRE: ${{ matrix.symfony-version }} + strategy: + fail-fast: false + matrix: + php: + - '8.0' + - '8.1' + - '8.2' + symfony-version: + - 5.* + - 6.* + dependencies: + - highest + include: + - php: '8.0' + symfony-version: 5.* + dependencies: lowest + - php: '8.0' + symfony-version: 6.* + dependencies: lowest + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: pcov + tools: flex + + - name: Setup Problem Matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Update PHPUnit + run: composer require --dev phpunit/phpunit ^9.3.9 --no-update + if: matrix.php == '8.0' && matrix.dependencies == 'lowest' + + - name: Install dependencies + uses: ramsey/composer-install@v1 + with: + dependency-versions: ${{ matrix.dependencies }} + composer-options: --prefer-dist + + - name: Run tests + run: vendor/bin/phpunit --coverage-clover=build/coverage-report.xml + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + with: + file: build/coverage-report.xml + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + + missing-optional-packages-tests: + name: Tests without optional packages + runs-on: ubuntu-latest + env: + SYMFONY_REQUIRE: ${{ matrix.symfony-version }} + strategy: + fail-fast: false + matrix: + include: + - php: '8.0' + dependencies: lowest + symfony-version: 5.4.* + - php: '8.1' + dependencies: highest + - php: '8.2' + dependencies: highest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: pcov + tools: flex + + - name: Setup Problem Matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Remove optional packages + run: composer remove doctrine/dbal doctrine/doctrine-bundle symfony/messenger symfony/twig-bundle symfony/cache symfony/http-client --dev --no-update + + - name: Install dependencies + uses: ramsey/composer-install@v1 + with: + dependency-versions: ${{ matrix.dependencies }} + composer-options: --prefer-dist + + - name: Run tests + run: vendor/bin/phpunit --coverage-clover=build/coverage-report.xml + + - name: Upload code coverage + uses: codecov/codecov-action@v3 + with: + file: build/coverage-report.xml + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9de7e0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.lock +package.xml +/vendor +.idea +.php-cs-fixer.cache +.phpunit.result.cache +docs/_build +var +tests/clover.xml +.env diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..dce2462 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,35 @@ +setRiskyAllowed(true) + ->setRules([ + '@PSR2' => true, + '@Symfony' => true, + '@Symfony:risky' => true, + 'array_syntax' => ['syntax' => 'short'], + 'concat_space' => ['spacing' => 'one'], + 'ordered_imports' => [ + 'imports_order' => ['class', 'function', 'const'], + ], + 'declare_strict_types' => true, + 'random_api_migration' => true, + 'yoda_style' => true, + 'self_accessor' => false, + 'phpdoc_no_useless_inheritdoc' => false, + 'phpdoc_to_comment' => false, + 'phpdoc_align' => [ + 'tags' => ['param', 'return', 'throws', 'type', 'var'], + ], + 'phpdoc_line_span' => [ + 'const' => 'multi', + 'method' => 'multi', + 'property' => 'multi', + ], + ]) + ->setFinder( + PhpCsFixer\Finder::create() + ->in([__DIR__ . '/src', __DIR__ . '/tests']) + ->exclude(['var']) + ); diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..38238cc --- /dev/null +++ b/AUTHORS @@ -0,0 +1 @@ +http://github.com/sanderverkuil/posthog-symfony/contributors diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e69de29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6cb2b33 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,90 @@ +

+ + PostHog + +

+ +> [!IMPORTANT] +> This repository and project are not backed by [PostHog](https://posthog.com). + +# Contributing to the PostHog SDK for Symfony + +We welcome contributions to `posthog-symfony` by the community. + +Please search the [issue tracker](https://github.com/sanderverkuil/posthog-symfony/issues) before creating a new issue (a problem or an improvement request). + +If you feel that you can fix or implement it yourself, please read on to learn how to submit your changes. + +## Submitting changes + +- Setup the development environment. +- Clone the `posthog-symfony` repository and prepare necessary changes. +- Add tests for your changes to `tests/`. +- Run tests and make sure all of them pass. +- Submit a pull request, referencing any issues it addresses. + +We will review your pull request as soon as possible. +Thank you for contributing! + +## Development environment + +### Clone the repository + +```bash +git clone git@github.com:sanderverkuil/posthog-symfony.git +``` + +Make sure that you have PHP 8.0+ installed. On macOS, we recommend using brew to install PHP. For Windows, we recommend an official PHP.net release. + +### Install the dependencies + +Dependencies are managed through [Composer](https://getcomposer.org/): + +```bash +composer install +``` + +### Running tests + +Tests can then be run via [PHPUnit](https://phpunit.de/): + +```bash +vendor/bin/phpunit +``` + +## Releasing a new version + +(only relevant for maintainers) + +Prerequisites: + +- All changes that should be released must be in the `main` branch. +- Every commit should follow the [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) guide. + +Manual Process: + +- Update CHANGELOG.md with the version to be released. +- On GitHub in the `posthog-symfony` repository go to "Actions" select the "Release" workflow. +- Click on "Run workflow" on the right side and make sure the `main` branch is selected. +- Set "Version to release" input field. Here you decide if it is a major, minor or patch release. (See "Versioning Policy" below) +- Click "Run Workflow" + +### Versioning Policy + +This project follows [semver](https://semver.org/), with three additions: + +- Semver says that major version `0` can include breaking changes at any time. Still, it is common practice to assume that only `0.x` releases (minor versions) can contain breaking changes while `0.x.y` releases (patch versions) are used for backwards-compatible changes (bugfixes and features). This project also follows that practice. + +- All undocumented APIs are considered internal. They are not part of this contract. + +We recommend pinning your version requirements against `1.x.*` or `1.x.y`. +Either one of the following is fine: + +```json +{ + "posthog/posthog-php": "^1.0", + "posthog/posthog-php": "^1" +} +``` + +A major release `N` implies the previous release `N-1` will no longer receive updates. We generally do not backport bugfixes to older versions unless they are security relevant. However, feel free to ask for backports of specific commits on the bug tracker. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..786daa0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Sander Verkuil + +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/MAKEFILE b/MAKEFILE new file mode 100644 index 0000000..8be46f7 --- /dev/null +++ b/MAKEFILE @@ -0,0 +1,21 @@ +.PHONY: pre-commit-check cs cs-dry-run + +cs: + vendor/bin/php-cs-fixer fix --verbose + +cs-dry-run: + vendor/bin/php-cs-fixer fix --verbose --dry-run + +phpstan: + vendor/bin/phpstan analyze + +psalm: + vendor/bin/psalm + +test: + vendor/bin/phpunit + +pre-commit-check: cs phpstan psalm test + +setup-git: + git config branch.autosetuprebase always diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ba5855 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +

+ + PostHog + +

+ +[![Latest Version on Packagist](https://img.shields.io/packagist/v/sanderverkuil/posthog-symfony.svg?style=flat-square)](https://packagist.org/packages/sanderverkuil/posthog-symfony) +[![Total Downloads](https://img.shields.io/packagist/dt/sanderverkuil/posthog-symfony.svg?style=flat-square)](https://packagist.org/packages/sanderverkuil/posthog-symfony) + +> [!IMPORTANT] +> This repository and project are not backed by [PostHog](https://posthog.com). + +# Unofficial PostHog SDK for Symfony + +This is the unofficial Symfony SDK for [PostHog](https://posthog.com/). + +## Getting Started + +Using this `posthog-symfony` SDK provides you with the following benefits: + +* Quickly integrate and configure PostHog for your Symfony app +* Out of the box, each event will contain the following data by default + - The currently authenticated user + - The Symfony environment + +### Install + +To install the SDK you will need to be using [Composer]([https://getcomposer.org/) +in your project. To install it please see the [docs](https://getcomposer.org/download/). + +```bash +composer require sanderverkuil/posthog-symfony +``` + +### Enable the Bundle + +If you installed the package using the Flex recipe, the bundle will be automatically enabled. Otherwise, enable it by adding it to the list +of registered bundles in the `Kernel.php` file of your project: + +```php +class AppKernel extends \Symfony\Component\HttpKernel\Kernel +{ + public function registerBundles(): array + { + return [ + // ... + new \PostHog\PostHogBundle\PostHogBundle(), + ]; + } + + // ... +} +``` + +The bundle will be enabled in all environments by default. +To enable event reporting, you'll need to add a key (see the next step). + +### Configure + +Add the [PostHog key](https://app.posthog.com/products) of your project. +Add the key to your `config/packages/posthog.yaml` file. + +Keep in mind that by leaving the `key` value empty (or undeclared), you will disable the PostHog integration:. + +```yaml +post_hog: + key: "" + host: "https://app.posthog.com/" # Or https://eu.posthog.com/ for EU +``` + +#### Optional: use custom HTTP factory/transport + +This uses HTTPlug to remain transport-agnostic, you need to install two packages that provide +[`php-http/async-client-implementation`](https://packagist.org/providers/php-http/async-client-implementation) +and [`psr/http-message-implementation`](https://packagist.org/providers/psr/http-message-implementation). + +The suggested packages are: +- the Symfony HTTP Client (`symfony/http-client`) +- Guzzle's message factory (`http-interop/http-factory-guzzle`) + +## Maintained versions + +* 0.x is actively maintained and developed on the master branch, and uses PostHog SDK 3.0; + +## Contributing to the SDK + +Please refer to [CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +Licensed under the MIT license, see [`LICENSE`](LICENSE) + +### Attributions + +- I would like to thank [@getsentry](https://github.com/getsentry) for inspiration on how to set up a symfony bundle with regards to general things. +- I would like to thank [@posthog](https://github.com/posthog) for their product. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..959972a --- /dev/null +++ b/codecov.yml @@ -0,0 +1 @@ +comment: false \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fc81be4 --- /dev/null +++ b/composer.json @@ -0,0 +1,80 @@ +{ + "name": "sanderverkuil/posthog-symfony", + "description": "Symfony integration for PostHog (https://posthog.com)", + "type": "symfony-bundle", + "require": { + "php": "^8.0", + "jean85/pretty-package-versions": "^1.0|^2.0", + "posthog/posthog-php": "^3.0", + "symfony/config": "^5.4.0|^6.0", + "symfony/console": "^5.4.0|^6.0", + "symfony/dependency-injection": "^5.4.0|^6.0", + "symfony/event-dispatcher": "^5.4.0|^6.0", + "symfony/http-kernel": "^5.4.0|^6.0", + "symfony/polyfill-php80": "^1.28", + "symfony/security-core": "^5.4.0|^6.0", + "symfony/security-http": "^5.4.0|^6.0" + }, + "license": "MIT", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "PostHog\\PostHogBundle\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "PostHog\\PostHogBundle\\Tests\\": "tests/" + } + }, + "authors": [ + { + "name": "Sander Verkuil", + "email": "s.verkuil@pm.me" + } + ], + "minimum-stability": "stable", + "require-dev": { + "ergebnis/composer-normalize": "^1.0|^2.0", + "friendsofphp/php-cs-fixer": "^3.35", + "monolog/monolog": "^1.0|^2.0|^3.4", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-phpunit": "^1.3", + "phpstan/phpstan-symfony": "^1.3", + "phpunit/phpunit": "^8.5.14|^9.3.9|^10.4", + "symfony/browser-kit": "^5.4.0|^6.0", + "symfony/cache": "^5.4.0|^6.0", + "symfony/dom-crawler": "^5.4.0|^6.0", + "symfony/framework-bundle": "^5.4.0|^6.0", + "symfony/http-client": "^5.4.0|^6.0", + "symfony/monolog-bundle": "^3.4", + "symfony/phpunit-bridge": "^5.4.0|^6.0", + "symfony/process": "^5.4.0|^6.0", + "symfony/yaml": "^5.4.0|^6.0", + "vimeo/psalm": "^5.15" + }, + "scripts": { + "tests": [ + "vendor/bin/phpunit --verbose" + ], + "phpcs": [ + "vendor/bin/php-cs-fixer fix --verbose --diff --dry-run" + ], + "phpstan": [ + "vendor/bin/phpstan analyse" + ], + "psalm": [ + "vendor/bin/psalm" + ] + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "ergebnis/composer-normalize": true, + "phpstan/extension-installer": true + } + } +} diff --git a/config/schema/posthog-1.0.xsd b/config/schema/posthog-1.0.xsd new file mode 100644 index 0000000..ae4d6f8 --- /dev/null +++ b/config/schema/posthog-1.0.xsd @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/config/services.xml b/config/services.xml new file mode 100644 index 0000000..5dec3b2 --- /dev/null +++ b/config/services.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..7c9820c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +includes: + +parameters: + level: 9 + paths: + - src + - tests + dynamicConstantNames: + - Symfony\Component\HttpKernel\Kernel::VERSION + - Symfony\Component\HttpKernel\Kernel::VERSION_ID + featureToggles: + disableRuntimeReflectionProvider: true diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..6b9a446 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,11 @@ + + + + + + + + tests + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..b00111f --- /dev/null +++ b/psalm.xml @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/src/Adapter/PostHogAdapter.php b/src/Adapter/PostHogAdapter.php new file mode 100644 index 0000000..9ec021a --- /dev/null +++ b/src/Adapter/PostHogAdapter.php @@ -0,0 +1,85 @@ +client = $client; + PH::init(client: $this->client); + } + + public function capture(array $message): bool + { + return PH::capture($message); + } + + public function identify(array $message): bool + { + return PH::identify($message); + } + + public function groupIdentify(array $message): bool + { + return PH::groupIdentify($message); + } + + public function isFeatureEnabled(string $key, string $distinctId, array $groups = [], array $personProperties = [], array $groupProperties = [], bool $onlyEvaluateLocally = false, bool $sendFeatureFlagEvents = true): null|bool + { + return PH::isFeatureEnabled($key, $distinctId, $groups, $personProperties, $groupProperties, $onlyEvaluateLocally, $sendFeatureFlagEvents); + } + + public function getFeatureFlag(string $key, string $distinctId, array $groups = [], array $personProperties = [], array $groupProperties = [], bool $onlyEvaluateLocally = false, bool $sendFeatureFlagEvents = true): null|bool|string + { + return PH::getFeatureFlag($key, $distinctId, $groups, $personProperties, $groupProperties, $onlyEvaluateLocally, $sendFeatureFlagEvents); + } + + public function getAllFlags(string $distinctId, array $groups = [], array $personProperties = [], array $groupProperties = [], bool $onlyEvaluateLocally = false) + { + return PH::getAllFlags($distinctId, $groups, $personProperties, $groupProperties, $onlyEvaluateLocally); + } + + public function fetchFeatureVariants(string $distinctId, array $groups = []): array + { + return PH::fetchFeatureVariants($distinctId, $groups); + } + + public function alias(array $message): bool + { + return PH::alias($message); + } + + public function raw(array $message): bool + { + return PH::raw($message); + } + + /** + * @return bool + */ + public function flush(): bool + { + $result = PH::flush(); + + if (is_bool($result)) { + return $result; + } + + if (is_string($result)) { + $decoded = json_decode($result, true); + + return array_key_exists('status', $decoded); + } + + return false; + } +} diff --git a/src/Client/ClientBuilder.php b/src/Client/ClientBuilder.php new file mode 100644 index 0000000..ee3a0ce --- /dev/null +++ b/src/Client/ClientBuilder.php @@ -0,0 +1,55 @@ +apiKey = (getenv(PostHog::ENV_API_KEY) ?: ''); + $this->options = $options; + } + + public function setApiKey(string $apiKey): void + { + $this->apiKey = $apiKey; + } + + public function setOptions(Options $options): void + { + $this->options = $options; + } + + public function setHttpClient(?HttpClient $httpClient): void + { + $this->httpClient = $httpClient; + } + + public function setPersonalApiKey(?string $personalApiKey): void + { + $this->personalApiKey = $personalApiKey; + } + + public function getClient(): Client + { + return new Client( + $this->apiKey, + $this->options->getOptions(), + $this->httpClient, + $this->personalApiKey + ); + } +} diff --git a/src/Client/ClientBuilderInterface.php b/src/Client/ClientBuilderInterface.php new file mode 100644 index 0000000..dbb0da2 --- /dev/null +++ b/src/Client/ClientBuilderInterface.php @@ -0,0 +1,9 @@ +consumerOptions; + $options['consumer'] = $this->consumer; + $options['host'] = $this->host; + $options['ssl'] = $this->ssl; + $options['maximum_backoff_duration'] = $this->maximumBackoffDuration; + $options['debug'] = $this->debug; + $options['timeout'] = $this->timeout; + + return $options; + } +} diff --git a/src/Command/PostHogTestCommand.php b/src/Command/PostHogTestCommand.php new file mode 100644 index 0000000..68b524a --- /dev/null +++ b/src/Command/PostHogTestCommand.php @@ -0,0 +1,33 @@ +writeln('Identifying...'); + $whoami = shell_exec('whoami'); + $posthog->identify(['distinctId' => $whoami]); + + $output->writeln('Sending test message...'); + + $captured = $posthog->capture([ + 'distinctId' => $whoami, + 'event' => 'test-event' + ]) + ? self::SUCCESS + : self::FAILURE; + + return $captured; + } +} diff --git a/src/Context.php b/src/Context.php new file mode 100644 index 0000000..f116452 --- /dev/null +++ b/src/Context.php @@ -0,0 +1,12 @@ +getDefinition(LoginListener::class); + + if (!class_exists(LoginSuccessEvent::class)) { + $listenerDefinition->addTag( + 'kernel.event_listener', + [ + 'event' => AuthenticationSuccessEvent::class, + 'method' => 'handleAuthenticationSuccessEvent', + ] + ); + } + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php new file mode 100644 index 0000000..67d83be --- /dev/null +++ b/src/DependencyInjection/Configuration.php @@ -0,0 +1,26 @@ +getRootNode(); + + $rootNode->children() + ->scalarNode('host')->defaultValue('https://app.posthog.com')->end() + ->scalarNode('key')->defaultValue(null)->end() + ->booleanNode('enabled')->defaultValue(false)->end() + ->scalarNode('user_prefix')->defaultValue('user')->end() + ->end(); + return $builder; + } +} diff --git a/src/DependencyInjection/PostHogExtension.php b/src/DependencyInjection/PostHogExtension.php new file mode 100644 index 0000000..22ef9d5 --- /dev/null +++ b/src/DependencyInjection/PostHogExtension.php @@ -0,0 +1,57 @@ +load('services.xml'); + + $this->registerConfiguration($container, $mergedConfig); + } + + /** + * @param array $config + */ + private function registerConfiguration(ContainerBuilder $container, array $config): void + { + $options = $config['options']; + + $clientBuilderDefinition = (new Definition(ClientBuilder::class)) + ->setArgument(0, new Reference('post_hog.client.options')) + ->addMethodCall('setSdkIdentifier', [PostHogBundle::SDK_IDENTIFIER]) + ->addMethodCall('setSdkVersion', [PostHogBundle::SDK_VERSION]) + ->addMethodCall('setApiKey', [$options['key']]) + ->addMethodCall('setOptions', [new Reference()]) + ; + + $container + ->setDefinition('post_hog.client', new Definition(Client::class)) + ->setPublic(false) + ->setFactory([$clientBuilderDefinition, 'getClient']); + } +} diff --git a/src/EventListener/LoginListener.php b/src/EventListener/LoginListener.php new file mode 100644 index 0000000..9d03cb9 --- /dev/null +++ b/src/EventListener/LoginListener.php @@ -0,0 +1,140 @@ +tokenStorage = $tokenStorage; + $this->postHog = $postHog; + } + + /** + * This method is called for each request handled by the framework. + */ + public function handleKernelRequestEvent(RequestEvent $event): void + { + if (null === $this->tokenStorage || !$this->isMainRequest($event)) { + return; + } + + $token = $this->tokenStorage->getToken(); + + if (null !== $token) { + $this->updateUserContext($token); + } + } + + /** + * This method is called after authentication was fully successful. It allows + * to set information like the username of the currently authenticated user + * and of the impersonator. + */ + public function handleLoginSuccessEvent(LoginSuccessEvent $event): void + { + $this->updateUserContext($event->getAuthenticatedToken()); + } + + /** + * This method is called when an authentication provider authenticates the + * user. It is the event closest to {@see LoginSuccessEvent} in versions of + * the framework where it doesn't exist. + */ + public function handleAuthenticationSuccessEvent(AuthenticationSuccessEvent $event): void + { + $this->updateUserContext($event->getAuthenticationToken()); + } + + private function updateUserContext(TokenInterface $token): void + { + if (!$this->isTokenAuthenticated($token)) { + return; + } + + $message = [ + 'distinctId' => $this->getUserIdentifier($token->getUser()), + ]; + + $impersonatorUser = $this->getImpersonatorUser($token); + + if (null !== $impersonatorUser) { + $message['$set'] = [ + 'impersonator_username' => $impersonatorUser, + ]; + } + + PostHog::identify($message); + } + + private function isTokenAuthenticated(TokenInterface $token): bool + { + if (method_exists($token, 'isAuthenticated') && !$token->isAuthenticated(false)) { + return false; + } + + return null !== $token->getUser(); + } + + private function getUserIdentifier($user): ?string + { + if ($user instanceof UserInterface) { + if (method_exists($user, 'getUserIdentifier')) { + return $user->getUserIdentifier(); + } + + if (method_exists($user, 'getUsername')) { + return $user->getUsername(); + } + } + + if (\is_string($user)) { + return $user; + } + + if (\is_object($user) && method_exists($user, '__toString')) { + return (string) $user; + } + + return null; + } + + private function getImpersonatorUser(TokenInterface $token): ?string + { + if ($token instanceof SwitchUserToken) { + return $this->getUserIdentifier($token->getOriginalToken()->getUser()); + } + + return null; + } + + protected function isMainRequest(KernelEvent $event): bool + { + if (method_exists($event, 'isMainRequest')) { + return $event->isMainRequest(); + } + if (method_exists($event, 'isMasterRequest')) { + return $event->isMasterRequest(); + } + + return true; + } +} diff --git a/src/EventListener/RequestListener.php b/src/EventListener/RequestListener.php new file mode 100644 index 0000000..23efaa6 --- /dev/null +++ b/src/EventListener/RequestListener.php @@ -0,0 +1,45 @@ +isMainRequest($event)) { + return; + } + } + + public function handleKernelControllerEvent(ControllerEvent $event): void + { + if (!$this->isMainRequest($event)) { + return; + } + + $route = $event->getRequest()->attributes->get('_route'); + + if (!\is_string($route)) { + return; + } + } + + private function isMainRequest(KernelEvent $event): bool + { + return method_exists($event, 'isMainRequest') + ? $event->isMainRequest() + : $event->isMasterRequest(); + } +} diff --git a/src/Exception/NotInitializedException.php b/src/Exception/NotInitializedException.php new file mode 100644 index 0000000..8b9b16b --- /dev/null +++ b/src/Exception/NotInitializedException.php @@ -0,0 +1,15 @@ + getenv(\PostHog\PostHog::ENV_HOST) ?: ''] + )); + } + + public function boot(): void + { + $client = null; + if (null !== $this->container && $this->container->has('post_hog.client')) { + $client = $this->container->get('post_hog.client'); + if (!$client instanceof Client) { + $client = null; + } + } + self::initialize($client); + } + + public function build(ContainerBuilder $container): void + { + parent::build($container); + $container->addCompilerPass(new AddLoginListenerTagPass()); + } +} diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..5f61699 --- /dev/null +++ b/src/functions.php @@ -0,0 +1,18 @@ +identify($message); +} diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php new file mode 100644 index 0000000..269e31d --- /dev/null +++ b/tests/BaseTestCase.php @@ -0,0 +1,11 @@ + 'https://app.posthog.com' + ] + ) + ); + } + + public function testExecuteSuccessfully(): void + { + $commandTester = $this->executeCommand(); + + $output = $commandTester->getOutput(); + + $this->assertSame('', $commandTester->getDisplay()); + + $this->assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + } + + private function executeCommand(): CommandTester + { + $command = new PostHogTestCommand(); + $command->setName('posthog:test'); + + $application = new Application(); + $application->add($command); + + $command = $application->find('posthog:test'); + $commandTester = new CommandTester($command); + $commandTester->execute([ + 'command' => $command->getName(), + ]); + + return $commandTester; + } +} diff --git a/tests/End2End/App/Controller/MainController.php b/tests/End2End/App/Controller/MainController.php new file mode 100644 index 0000000..1617930 --- /dev/null +++ b/tests/End2End/App/Controller/MainController.php @@ -0,0 +1,28 @@ + + +PostHog - Demo + + +

Welcome

+

Welcome to your new experience matey.

+ + +HTML); + } +} diff --git a/tests/End2End/App/Kernel.php b/tests/End2End/App/Kernel.php new file mode 100644 index 0000000..0fecd06 --- /dev/null +++ b/tests/End2End/App/Kernel.php @@ -0,0 +1,34 @@ +load(__DIR__ . '/config.yml'); + + if (self::VERSION_ID >= 50000) { + $loader->load(__DIR__ . '/deprecations_for_5.yml'); + } + + if (self::VERSION_ID >= 60000) { + $loader->load(__DIR__ . '/deprecations_for_6.yml'); + } + } +} diff --git a/tests/End2End/App/config.yml b/tests/End2End/App/config.yml new file mode 100644 index 0000000..493956e --- /dev/null +++ b/tests/End2End/App/config.yml @@ -0,0 +1,17 @@ +post_hog: + host: https://app.posthog.com/ + key: '' + +framework: + router: { resource: "%kernel.project_dir%/routing.yml" } + secret: secret + test: ~ + +services: + +monolog: + handlers: + main: + type: stream + path: "%kernel.logs_dir%/%kernel.environment%.log" + level: debug diff --git a/tests/End2End/App/deprecations_for_5.yml b/tests/End2End/App/deprecations_for_5.yml new file mode 100644 index 0000000..5ed9bbb --- /dev/null +++ b/tests/End2End/App/deprecations_for_5.yml @@ -0,0 +1,3 @@ +framework: + router: + utf8: true diff --git a/tests/End2End/App/deprecations_for_6.yml b/tests/End2End/App/deprecations_for_6.yml new file mode 100644 index 0000000..8d2f862 --- /dev/null +++ b/tests/End2End/App/deprecations_for_6.yml @@ -0,0 +1,2 @@ +framework: + http_method_override: false diff --git a/tests/End2End/App/routing.yml b/tests/End2End/App/routing.yml new file mode 100644 index 0000000..e5ef8d7 --- /dev/null +++ b/tests/End2End/App/routing.yml @@ -0,0 +1,4 @@ +app: + prefix: / + type: 'attributes' + resource: 'Controller/*' diff --git a/tests/End2End/End2EndTest.php b/tests/End2End/End2EndTest.php new file mode 100644 index 0000000..52c910c --- /dev/null +++ b/tests/End2End/End2EndTest.php @@ -0,0 +1,43 @@ + false]); + + $client->request('GET', '/'); + + $response = $client->getResponse(); + + $this->assertSame(200, $response->getStatusCode()); + } + + public function testCommandExecution(): void + { + self::bootKernel(); + $application = new Application(self::$kernel); + $command = $application->find('posthog:test'); + + $tester = new CommandTester($command); + + $result = $tester->execute([]); + + $this->assertSame(0, $tester->getStatusCode()); + } +} diff --git a/tests/EventListener/Fixtures/UserWithIdentifierStub.php b/tests/EventListener/Fixtures/UserWithIdentifierStub.php new file mode 100644 index 0000000..ab1e9db --- /dev/null +++ b/tests/EventListener/Fixtures/UserWithIdentifierStub.php @@ -0,0 +1,46 @@ +username = $username; + } + + public function getUserIdentifier(): string + { + return $this->getUsername(); + } + + public function getUsername(): string + { + return $this->username; + } + + public function getRoles(): array + { + return []; + } + + public function getPassword(): ?string + { + return null; + } + + public function getSalt(): ?string + { + return null; + } + + public function eraseCredentials(): void + { + } +} diff --git a/tests/EventListener/LoginListenerTest.php b/tests/EventListener/LoginListenerTest.php new file mode 100644 index 0000000..29939e9 --- /dev/null +++ b/tests/EventListener/LoginListenerTest.php @@ -0,0 +1,132 @@ +postHog = $this->createMock(PostHogInterface::class); + $this->tokenStorage = $this->createMock(TokenStorageInterface::class); + $this->listener = new LoginListener($this->postHog, $this->tokenStorage); + } + + /** + * @dataProvider authenticationTokenDataProvider + */ + public function testHandleLoginSuccessEvent(TokenInterface $token, ?UserDataBag $user, ?UserDataBag $expectedUser): void + { + if (!class_exists(LoginSuccessEvent::class)) { + $this->markTestSkipped('This test is incompatible with versions of Symfony where the LoginSuccessEvent event does not exist.'); + } + + $this->listener->handleLoginSuccessEvent(new LoginSuccessEvent( + $this->createMock(AuthenticatorInterface::class), + new SelfValidatingPassport(new UserBadge('foo_passport_user')), + $token, + new Request(), + null, + 'main' + )); + } + + public function authenticationTokenDataProvider(): \Generator + { + yield 'If the username is already set on the User context, then it is not overridden' => [ + new AuthenticatedTokenStub(new UserWithIdentifierStub()), + new UserDataBag('bar_user'), + new UserDataBag('bar_user'), + ]; + + yield 'If the username is not set on the User context, then it is retrieved from the token' => [ + new AuthenticatedTokenStub(new UserWithIdentifierStub()), + null, + new UserDataBag('foo_user'), + ]; + + yield 'If the user is being impersonated, then the username of the impersonator is set on the User context' => [ + (static function (): SwitchUserToken { + if (version_compare(Kernel::VERSION, '5.0.0', '<')) { + return new SwitchUserToken( + new UserWithIdentifierStub(), + null, + 'foo_provider', + ['ROLE_USER'], + new AuthenticatedTokenStub(new UserWithIdentifierStub('bar_user')) + ); + } + + return new SwitchUserToken( + new UserWithIdentifierStub(), + 'main', + ['ROLE_USER'], + new AuthenticatedTokenStub(new UserWithIdentifierStub('bar_user')) + ); + })(), + null, + UserDataBag::createFromArray([ + 'id' => 'foo_user', + 'impersonator_username' => 'bar_user', + ]), + ]; + } +} + +final class AuthenticatedTokenStub extends AbstractToken +{ + /** + * @param UserInterface|\Stringable|string|null $user + */ + public function __construct($user) + { + parent::__construct(); + + if (null !== $user) { + $this->setUser($user); + } + + if (method_exists($this, 'setAuthenticated')) { + $this->setAuthenticated(true); + } + } + + public function getCredentials(): ?string + { + return null; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..a075e1e --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,5 @@ +