diff --git a/.buildscript/e2e.sh b/.buildscript/e2e.sh index ed25afe..5ccdab4 100755 --- a/.buildscript/e2e.sh +++ b/.buildscript/e2e.sh @@ -6,7 +6,7 @@ if [ "$RUN_E2E_TESTS" != "true" ]; then echo "Skipping end to end tests." else echo "Running end to end tests..." - wget https://github.com/segmentio/library-e2e-tester/releases/download/0.2.1/tester_linux_amd64 -O tester + wget https://github.com/segmentio/library-e2e-tester/releases/download/0.4.1-pre1/tester_linux_amd64 -O tester chmod +x tester ./tester -path='./bin/analytics' echo "End to end tests completed!" diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..193a7e7 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,21 @@ +codecov: + notify: + after_n_builds: 2 + +coverage: + round: nearest + # Status will be green when coverage is between 70 and 100%. + range: "70...100" + status: + project: + default: + threshold: 2% + paths: + - "src" + patch: + default: + threshold: 0% + paths: + - "src" + +comment: false diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ea28cc7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Global users +@lubird @bsneed @pooyaj \ No newline at end of file diff --git a/.github/workflows/create_jira.yml b/.github/workflows/create_jira.yml new file mode 100644 index 0000000..8180ac0 --- /dev/null +++ b/.github/workflows/create_jira.yml @@ -0,0 +1,39 @@ +name: Create Jira Ticket + +on: + issues: + types: + - opened + +jobs: + create_jira: + name: Create Jira Ticket + runs-on: ubuntu-latest + environment: IssueTracker + steps: + - name: Checkout + uses: actions/checkout@master + - name: Login + uses: atlassian/gajira-login@master + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_TOKEN }} + JIRA_EPIC_KEY: ${{ secrets.JIRA_EPIC_KEY }} + JIRA_PROJECT: ${{ secrets.JIRA_PROJECT }} + + - name: Create + id: create + uses: atlassian/gajira-create@master + with: + project: ${{ secrets.JIRA_PROJECT }} + issuetype: Bug + summary: | + [${{ github.event.repository.name }}] (${{ github.event.issue.number }}): ${{ github.event.issue.title }} + description: | + Github Link: ${{ github.event.issue.html_url }} + ${{ github.event.issue.body }} + fields: '{"parent": {"key": "${{ secrets.JIRA_EPIC_KEY }}"}}' + + - name: Log created issue + run: echo "Issue ${{ steps.create.outputs.issue }} was created" \ No newline at end of file diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..9dca93b --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,58 @@ +name: E2E Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + inputs: + e2e_tests_ref: + description: 'Branch or ref of sdk-e2e-tests to use' + required: false + default: 'main' + +jobs: + e2e-tests: + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + + steps: + - name: Checkout SDK + uses: actions/checkout@v4 + with: + path: sdk + + - name: Checkout sdk-e2e-tests + uses: actions/checkout@v4 + with: + repository: segmentio/sdk-e2e-tests + ref: ${{ inputs.e2e_tests_ref || 'main' }} + token: ${{ secrets.E2E_TESTS_TOKEN }} + path: sdk-e2e-tests + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: curl, json + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run E2E tests + working-directory: sdk-e2e-tests + run: | + ./scripts/run-tests.sh \ + --sdk-dir "${{ github.workspace }}/sdk/e2e-cli" \ + --cli "php ${{ github.workspace }}/sdk/e2e-cli/main.php" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-test-results + path: sdk-e2e-tests/test-results/ + if-no-files-found: ignore diff --git a/.github/workflows/publish-e2e-cli.yml b/.github/workflows/publish-e2e-cli.yml new file mode 100644 index 0000000..ec647f0 --- /dev/null +++ b/.github/workflows/publish-e2e-cli.yml @@ -0,0 +1,39 @@ +name: Publish E2E CLI + +on: + push: + branches: [master] + paths: + - 'e2e-cli/**' + - 'lib/**' + schedule: + - cron: '0 0 1 * *' + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout SDK + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: curl, json + + - name: Prepare artifact + run: | + mkdir -p artifact + cp e2e-cli/main.php artifact/ + cp e2e-cli/e2e-config.json artifact/ + cp e2e-cli/run-e2e.sh artifact/ + cp -r lib artifact/lib + + - name: Upload CLI artifact + uses: actions/upload-artifact@v4 + with: + name: e2e-cli-php + path: artifact/ + retention-days: 90 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..53bf512 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,141 @@ +name: "Tests" + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + + coding-standard: + runs-on: ubuntu-22.04 + name: Coding standards + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + tools: cs2pr + + # Install dependencies and handle caching in one go. + # @link https://github.com/marketplace/actions/install-composer-dependencies + - name: Install Composer dependencies + uses: "ramsey/composer-install@v2" + + - name: Check coding standards + continue-on-error: true + run: ./vendor/bin/phpcs -s --report-full --report-checkstyle=./phpcs-report.xml + + - name: Show PHPCS results in PR + run: cs2pr ./phpcs-report.xml + + lint: + runs-on: ubuntu-22.04 + strategy: + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3'] + experimental: [false] + + name: "Lint: PHP ${{ matrix.php }}" + continue-on-error: ${{ matrix.experimental }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: cs2pr + + # Install dependencies and handle caching in one go. + # @link https://github.com/marketplace/actions/install-composer-dependencies + - name: Install Composer dependencies + uses: "ramsey/composer-install@v2" + + - name: Lint against parse errors + if: ${{ matrix.php != '8.1' }} + run: composer lint -- --checkstyle | cs2pr + + - name: Lint against parse errors (PHP 8.1) + if: ${{ matrix.php == '8.1' }} + run: composer lint + + test: + needs: ['coding-standard', 'lint'] + runs-on: ubuntu-22.04 + strategy: + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3'] + coverage: [false] + experimental: [false] + include: + # Run code coverage on high/low PHP. + - php: '7.4' + coverage: true + experimental: false + - php: '8.0' + coverage: true + experimental: false + - php: '8.1' + coverage: true + experimental: false + - php: '8.2' + coverage: true + experimental: false + - php: '8.3' + coverage: true + experimental: false + + name: "Test: PHP ${{ matrix.php }}" + + continue-on-error: ${{ matrix.experimental }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: ${{ matrix.coverage && 'xdebug' || 'none' }} + ini-values: sendmail_path=/usr/sbin/sendmail -t -i, error_reporting=E_ALL, display_errors=On + extensions: imap, mbstring, intl, ctype, filter, hash + + # Install dependencies and handle caching in one go. + # @link https://github.com/marketplace/actions/install-composer-dependencies + - name: Install dependencies + if: ${{ matrix.php != '8.1' }} + uses: "ramsey/composer-install@v2" + + - name: Install dependencies - ignore-platform-reqs + if: ${{ matrix.php == '8.1' }} + uses: "ramsey/composer-install@v2" + with: + composer-options: --ignore-platform-reqs + + - name: Run tests, no code coverage + if: ${{ matrix.coverage }} + run: ./vendor/bin/phpunit --no-coverage + + - name: Run tests with code coverage + if: ${{ matrix.coverage }} + run: ./vendor/bin/phpunit + + - name: Send coverage report to Codecov + if: ${{ success() && matrix.coverage }} + uses: codecov/codecov-action@v5 + with: + files: ./build/logs/clover.xml + fail_ci_if_error: true + verbose: true + env: + CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} diff --git a/.gitignore b/.gitignore index de6e5ad..5c16b2f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ composer.phar test/analytics.log /.vscode .phplint-cache +/.idea +clover.xml +junit.xml +.phpunit.result.cache +/build/* diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index dd6bc10..0000000 --- a/.travis.yml +++ /dev/null @@ -1,20 +0,0 @@ -language: php -dist: precise -php: - - 5.6 - - 5.5 - - 5.4 - - 5.3 - - hhvm - -matrix: - allow_failures: - - php: hhvm - -script: - - if [[ ! ${TRAVIS_PHP_VERSION:0:3} < "5.5" ]]; then composer require overtrue/phplint; composer require squizlabs/php_codesniffer; ./vendor/bin/phplint; ./vendor/bin/phpcs; fi - - phpunit - - .buildscript/e2e.sh - -after_success: - - bash <(curl -s https://codecov.io/bash) diff --git a/HISTORY.md b/HISTORY.md index 6dce29a..1642c3f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,4 +1,99 @@ +3.8.2 / 2026-03-11 +================== + + + +3.8.2 / 2026-03-11 +================== + + * Fix OS command injection in ForkCurl consumer (#246) + +3.8.1 / 2025-01-27 +================== + + * Convert the exec output to string (#239) + +3.8.0 / 2024-02-15 +================== + + * Include support for 8.3 (#231) + + +3.7.0 / 2023-09-11 +================== + + * Convert to github actions + * Remove circleci config/files + * Update autoloader + + +3.6.0 / 2023-03-28 +================== + + * Issue #208 Correct autoload require statement (#209) + * Fix missing version information (#207) + * Include support for 8.1 * 8.2 (#210) + + +3.5.0 / 2022-08-17 +================== + + * Correct Payload size check of 512kb (#202) + * Add new consumer configurable options: curl_timeout, curl_connecttimeout, max_item_size_bytes, max_queue_size_bytes (#192, #197, #198) + * Deprecate HTTP Option (#194 & #195) + + +3.0.0 / 2021-10-14 +================== + + * PSR-12 coding standard with Slevomat and phcs extensions + * Namespace and file rearrangement to follow PSR-4 naming scheme and more logical separation + * Provide strict types for all properties, parameters, and return values + * Add an exception class so we can have segment-specific exceptions + * Add dependencies on JSON extension + * Add dependency on the Roave security checker + * Since the library already required a minimum of PHP 7.4, make use of PHP 7.4+ features, and avoid compat issues with 8.0 + * More sensible error handling, don't try to catch exceptions that are never thrown + * Extensive linting and static analysis using phpcs, psalm, phpstan, and PHPStorm to spot issues + + +2.0.0 / 2021-07-16 +================== + + * Modify Endpoint to match API docs (#171) + * usleep in flush() causes unexpected delays on page loads (#173) + * Support PHP 8 (#152) + * Remove Support for PHP 7.2 + * Namespacing (#182) + + +1.8.0 / 2021-05-31 +================== + + * Fix socket return response (#174) + * API Endpoint update (#168) + * Update Batch Size Check (#168) + * Remove messageID override capabilities (#163) + * Update flush sleep waiting period (#161) + +1.7.0 / 2021-03-10 +======================= + + * Retry Network errors (#136) + * Update Tests [Improvement] (#132) + * Updtate Readme Status Badging (#139) + * Bump e2e tests to latest version [Improvement] (#142) + * Add Limits to message, batch and memory usage [Feature] (#137) + * Add Configurable flush parameters [Feature] (#135) + * Add ability to use custom consumer [Feature] (#61) + * Add ability to set file permissions [Feature] (#122) + * Fix curl error handler [Improvement] (#97) + * Fix timestamp implementation for microseconds (#94) + * Modify max queue size setting to match requirements (#153, #146) + * Add ability to set userid as zero (#157) + + 1.6.1-beta / 2018-05-01 ======================= @@ -32,7 +127,7 @@ 1.5.1 / 2017-04-06 ================== - * Use require_once() instead of require(). Fixes issue where seperate plugins in systems such as Moodle break because of class redeclaration when using seperate internal versions of Segment.io. + * Use require_once() instead of require(). Fixes issue where separate plugins in systems such as Moodle break because of class redeclaration when using separate internal versions of Segment.io. 1.5.0 / 2017-03-03 ================== @@ -40,7 +135,7 @@ * Adding context.library.consumer to all PHP events * libcurl consumer will retry once if http response is not 200 * update link to php docs - * improve portability and reliability of Makefile accros different platforms (#74) + * improve portability and reliability of Makefile across different platforms (#74) 1.4.2 / 2016-07-11 ================== diff --git a/Makefile b/Makefile index 7903cb5..3819410 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,8 @@ lint: dependencies then \ php ./composer.phar require overtrue/phplint --dev; \ php ./composer.phar require squizlabs/php_codesniffer --dev; \ + php ./composer.phar require dealerdirect/phpcodesniffer-composer-installer --dev; \ + ./vendor/bin/phplint; \ ./vendor/bin/phpcs; \ else \ @@ -26,8 +28,7 @@ lint: dependencies release: @printf "releasing ${VERSION}..." - @printf ' ./lib/Segment/Version.php - @node -e "var fs = require('fs'), pkg = require('./composer'); pkg.version = '${VERSION}'; fs.writeFileSync('./composer.json', JSON.stringify(pkg, null, '\t'));" + @printf ' ./lib/Version.php @git changelog -t ${VERSION} @git release ${VERSION} @@ -37,4 +38,4 @@ clean: vendor \ composer.lock -.PHONY: boostrap release clean +.PHONY: bootstrap release clean diff --git a/README.md b/README.md index 946db82..40d8a28 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,55 @@ analytics-php ============== - -[![Build Status](https://travis-ci.org/segmentio/analytics-php.png?branch=master)](https://travis-ci.org/segmentio/analytics-php) +[![Test status](https://github.com/segmentio/analytics-php/workflows/Tests/badge.svg)](https://github.com/segmentio/analytics-php/actions) +[![codecov.io](https://codecov.io/gh/segmentio/analytics-php/branch/master/graph/badge.svg?token=iORZpwmYmM)](https://codecov.io/gh/segmentio/analytics-php) analytics-php is a php client for [Segment](https://segment.com) +### ⚠️ Maintenance ⚠️ +This library is in maintenance mode. It will send data as intended, but receive no new feature support and only critical maintenance updates from Segment. + +
+ +

You can't fix what you can't measure

+
+ +Analytics helps you measure your users, product, and business. It unlocks insights into your app's funnel, core business metrics, and whether you have product-market fit. + +## How to get started +1. **Collect analytics data** from your app(s). + - The top 200 Segment companies collect data from 5+ source types (web, mobile, server, CRM, etc.). +2. **Send the data to analytics tools** (for example, Google Analytics, Amplitude, Mixpanel). + - Over 250+ Segment companies send data to eight categories of destinations such as analytics tools, warehouses, email marketing and remarketing systems, session recording, and more. +3. **Explore your data** by creating metrics (for example, new signups, retention cohorts, and revenue generation). + - The best Segment companies use retention cohorts to measure product market fit. Netflix has 70% paid retention after 12 months, 30% after 7 years. + +[Segment](https://segment.com) collects analytics data and allows you to send it to more than 250 apps (such as Google Analytics, Mixpanel, Optimizely, Facebook Ads, Slack, Sentry) just by flipping a switch. You only need one Segment code snippet, and you can turn integrations on and off at will, with no additional code. [Sign up with Segment today](https://app.segment.com/signup). + +### Why? +1. **Power all your analytics apps with the same data**. Instead of writing code to integrate all of your tools individually, send data to Segment, once. + +2. **Install tracking for the last time**. We're the last integration you'll ever need to write. You only need to instrument Segment once. Reduce all of your tracking code and advertising tags into a single set of API calls. + +3. **Send data from anywhere**. Send Segment data from any device, and we'll transform and send it on to any tool. + +4. **Query your data in SQL**. Slice, dice, and analyze your data in detail with Segment SQL. We'll transform and load your customer behavioral data directly from your apps into Amazon Redshift, Google BigQuery, or Postgres. Save weeks of engineering time by not having to invent your own data warehouse and ETL pipeline. + + For example, you can capture data on any app: + ```js + analytics.track('Order Completed', { price: 99.84 }) + ``` + Then, query the resulting data in SQL: + ```sql + select * from app.order_completed + order by price desc + ``` + +### 🚀 Startup Program +
+ +
+If you are part of a new startup (<$5M raised, <2 years since founding), we just launched a new startup program for you. You can get a Segment Team plan (up to $25,000 value in Segment credits) for free up to 2 years — apply here! + ## Documentation Documentation is available at [segment.com/docs/sources/server/php](https://segment.com/docs/sources/server/php/) diff --git a/bin/analytics b/bin/analytics index 1472054..b50ae59 100755 --- a/bin/analytics +++ b/bin/analytics @@ -1,106 +1,94 @@ #!/usr/bin/env php $options['userId'], - 'event' => $options['event'], - 'properties' => parse_json($options['properties']) - )); - break; - - case 'identify': - Segment::identify(array( - 'userId' => $options['userId'], - 'traits' => parse_json($options['traits']) - )); - break; - - case 'page': - Segment::page(array( - 'userId' => $options['userId'], - 'name' => $options['name'], - 'properties' => parse_json($options['properties']) - )); - break; - - case 'group': - Segment::identify(array( - 'userId' => $options['userId'], - 'groupId' => $options['groupId'], - 'traits' => parse_json($options['traits']) - )); - break; - - case 'alias': - Segment::alias(array( - 'userId' => $options['userId'], - 'previousId' => $options['previousId'] - )); - break; - - default: - error(usage()); - break; + case 'track': + Segment::track(array( + 'userId' => $options['userId'], + 'event' => $options['event'], + 'properties' => parse_json($options['properties']) + )); + break; + case 'identify': + Segment::identify(array( + 'userId' => $options['userId'], + 'traits' => parse_json($options['traits']) + )); + break; + case 'page': + Segment::page(array( + 'userId' => $options['userId'], + 'name' => $options['name'], + 'properties' => parse_json($options['properties']) + )); + break; + case 'group': + Segment::identify(array( + 'userId' => $options['userId'], + 'groupId' => $options['groupId'], + 'traits' => parse_json($options['traits']) + )); + break; + case 'alias': + Segment::alias(array( + 'userId' => $options['userId'], + 'previousId' => $options['previousId'] + )); + break; + default: + error(usage()); } Segment::flush(); -function usage() { - return "\n Usage: analytics --type [options]\n\n"; +function usage(): string +{ + return "\n Usage: analytics --type [options]\n\n"; } -function error($message) { - print("$message\n\n"); - exit(1); -} - -function parse_json($input) { - if (empty($input)) { - return null; - } - - return json_decode($input); +function error($message): void +{ + print("$message\n\n"); + exit(1); } -function parse_timestamp($input) { - if (empty($input)) { - return null; - } +function parse_json($input): ?array +{ + if (empty($input)) { + return null; + } - return strtotime($input); + return json_decode($input, true); } diff --git a/bootstrap.php b/bootstrap.php new file mode 100644 index 0000000..e236436 --- /dev/null +++ b/bootstrap.php @@ -0,0 +1,23 @@ +=5.3.3" + "php": "^7.4 || ^8.0", + "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "~4.0", - "overtrue/phplint": "^1.1", - "squizlabs/php_codesniffer": "^3.3" + "phpunit/phpunit": "~9.5", + "overtrue/phplint": "^3.0", + "squizlabs/php_codesniffer": "^3.6", + "roave/security-advisories": "dev-latest", + "slevomat/coding-standard": "^7.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "dealerdirect/phpcodesniffer-composer-installer": "^0.7" + }, + "suggest": { + "ext-curl": "For using the curl HTTP client", + "ext-zlib": "For using compression" }, "autoload": { - "files": [ - "lib/Segment.php" - ] + "psr-4": { + "Segment\\": "lib/" + } + }, + "autoload-dev": { + "psr-4": { + "Segment\\Test\\": "test/" + } }, "bin": [ "bin/analytics" - ] + ], + "scripts": { + "test": "./vendor/bin/phpunit --no-coverage", + "check": "./vendor/bin/phpcs", + "cf": "./vendor/bin/phpcbf", + "coverage": "./vendor/bin/phpunit", + "lint": [ + "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php,phps --exclude vendor --exclude .git --exclude build" + ] + }, + "config": { + "allow-plugins": { + "segmentio/*": true, + "dealerdirect/phpcodesniffer-composer-installer": true + } + } } diff --git a/e2e-cli/README.md b/e2e-cli/README.md new file mode 100644 index 0000000..4126b08 --- /dev/null +++ b/e2e-cli/README.md @@ -0,0 +1,131 @@ +# analytics-php e2e-cli + +A small CLI tool that drives the analytics-php SDK in end-to-end tests. + +## Requirements + +- PHP 7.4 or 8.x + +No `composer install` is required — `main.php` uses a simple `spl_autoload_register` +to load the SDK classes directly from `../lib/`. + +## Usage + +```bash +php main.php --input '' +``` + +### Input JSON format + +```json +{ + "writeKey": "YOUR_WRITE_KEY", + "apiHost": "https://api.segment.io", + "sequences": [ + { + "delayMs": 0, + "events": [ + { + "type": "track", + "event": "Test Event", + "userId": "user-1", + "properties": { "plan": "pro" } + }, + { + "type": "identify", + "userId": "user-1", + "traits": { "email": "user@example.com" } + }, + { + "type": "page", + "userId": "user-1", + "name": "Home", + "category": "Nav" + }, + { + "type": "screen", + "userId": "user-1", + "name": "Main Screen" + }, + { + "type": "alias", + "userId": "new-id", + "previousId": "old-id" + }, + { + "type": "group", + "userId": "user-1", + "groupId": "group-1", + "traits": { "name": "Acme Corp" } + } + ] + } + ], + "config": { + "flushAt": 15, + "timeout": 10 + } +} +``` + +| Field | Description | +|---|---| +| `writeKey` | Segment write key | +| `apiHost` | Full URL of the Segment API host (scheme is stripped; PHP SDK always uses HTTPS) | +| `sequences[].delayMs` | Milliseconds to wait before processing this sequence | +| `sequences[].events` | List of events to send | +| `config.flushAt` | Number of events to accumulate before auto-flushing (maps to `flush_at`) | +| `config.timeout` | cURL timeout in seconds (maps to `curl_timeout`) | + +### Supported event types + +`track`, `identify`, `page`, `screen`, `alias`, `group` + +### Output JSON (stdout) + +On success: + +```json +{"success": true, "sentBatches": 1} +``` + +On failure: + +```json +{"success": false, "sentBatches": 0, "error": "HTTP 400: Bad Request"} +``` + +Exit code is `0` on success and `1` on failure. Debug logs are written to **stderr**. + +## Running E2E tests + +```bash +./run-e2e.sh +``` + +By default this expects the `sdk-e2e-tests` repo to be checked out alongside +the `analytics-php` repo: + +``` +parent/ + analytics-php/ + e2e-cli/ ← you are here + sdk-e2e-tests/ +``` + +Override the location with an environment variable: + +```bash +E2E_TESTS_DIR=/path/to/sdk-e2e-tests ./run-e2e.sh +``` + +Any additional arguments are forwarded to `sdk-e2e-tests/scripts/run-tests.sh`. + +## How it works + +1. `main.php` parses `--input` JSON from `$argv`. +2. A `Segment\Client` is created with `lib_curl` consumer and the provided options. +3. Each sequence is processed in order; `delayMs` introduces an optional pause. +4. After all events are enqueued, `flush()` is called to send them synchronously. +5. Errors captured by the `error_handler` callback or a `false` return from + `flush()` cause the script to exit with code `1`. diff --git a/e2e-cli/composer.json b/e2e-cli/composer.json new file mode 100644 index 0000000..cea3b6f --- /dev/null +++ b/e2e-cli/composer.json @@ -0,0 +1,13 @@ +{ + "name": "segment/analytics-php-e2e-cli", + "description": "E2E testing CLI for Segment Analytics PHP", + "type": "project", + "require": { + "php": "^7.4 || ^8.0" + }, + "autoload": { + "psr-4": { + "Segment\\": "../lib/" + } + } +} diff --git a/e2e-cli/e2e-config.json b/e2e-cli/e2e-config.json new file mode 100644 index 0000000..071d5fc --- /dev/null +++ b/e2e-cli/e2e-config.json @@ -0,0 +1,7 @@ +{ + "sdk": "php", + "test_suites": "basic", + "auto_settings": false, + "patch": null, + "env": {} +} diff --git a/e2e-cli/main.php b/e2e-cli/main.php new file mode 100644 index 0000000..4695981 --- /dev/null +++ b/e2e-cli/main.php @@ -0,0 +1,319 @@ +' + * + * Input JSON format: + * { + * "writeKey": "...", + * "apiHost": "https://...", + * "sequences": [ + * { + * "delayMs": 0, + * "events": [ + * {"type": "track", "event": "Test", "userId": "user-1", "properties": {...}}, + * ... + * ] + * } + * ], + * "config": { + * "flushAt": 15, + * "timeout": 10 + * } + * } + * + * Output JSON to stdout: + * {"success": true, "sentBatches": 1} + * {"success": false, "sentBatches": 0, "error": "..."} + * + * Exit code: 0 on success, 1 on failure. + */ + +// Autoload the Segment SDK from the parent lib/ directory. +spl_autoload_register(function (string $class): void { + $prefix = 'Segment\\'; + $baseDir = __DIR__ . '/../lib/'; + + if (strncmp($prefix, $class, strlen($prefix)) !== 0) { + return; + } + + $relative = substr($class, strlen($prefix)); + $file = $baseDir . str_replace('\\', '/', $relative) . '.php'; + + if (file_exists($file)) { + require $file; + } +}); + +/** + * LibCurl subclass that allows overriding the protocol (http:// vs https://). + * The base class hardcodes $protocol = 'https://', so we extend it to support + * plain-HTTP targets used by the mock test server. + */ +class E2eLibCurl extends \Segment\Consumer\LibCurl +{ + public function __construct(string $secret, array $options = []) + { + parent::__construct($secret, $options); + if (isset($options['protocol'])) { + $this->protocol = $options['protocol']; + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function debugLog(string $msg): void +{ + fwrite(STDERR, '[e2e-cli] ' . $msg . PHP_EOL); +} + +function outputResult(bool $success, int $sentBatches, string $error = ''): void +{ + $result = [ + 'success' => $success, + 'sentBatches' => $sentBatches, + ]; + if (!$success && $error !== '') { + $result['error'] = $error; + } + echo json_encode($result) . PHP_EOL; +} + +/** + * Parse the --input argument from $argv. + * + * @param array $argv + * @return string|null + */ +function parseInputArg(array $argv): ?string +{ + for ($i = 1, $iMax = count($argv); $i < $iMax; $i++) { + if ($argv[$i] === '--input' && isset($argv[$i + 1])) { + return $argv[$i + 1]; + } + if (strncmp($argv[$i], '--input=', 8) === 0) { + return substr($argv[$i], 8); + } + } + return null; +} + +/** + * Given a full URL like "https://api.segment.io" or "http://localhost:8080", + * return just the host[:port] portion that the PHP SDK expects. + * + * The PHP SDK prepends "https://" itself (hardcoded in QueueConsumer), so we + * strip the scheme here and only keep host + optional port. + * + * @param string $apiHost + * @return string + */ +function parseHost(string $apiHost): string +{ + // Remove trailing slash + $apiHost = rtrim($apiHost, '/'); + + // Strip scheme + $apiHost = preg_replace('#^https?://#', '', $apiHost); + + // Remove any path component — keep only host[:port] + $parts = explode('/', $apiHost, 2); + + return $parts[0]; +} + +/** + * Build the options array for Segment\Client. + * + * @param array $input + * @param array &$errors collected error messages + * @return array + */ +function buildClientOptions(array $input, array &$errors): array +{ + $config = $input['config'] ?? []; + $apiHost = $input['apiHost'] ?? ''; + + // Determine protocol from the apiHost scheme (default https://). + $scheme = 'https://'; + if (preg_match('#^(https?)://#i', $apiHost, $m)) { + $scheme = strtolower($m[1]) . '://'; + } + + $options = [ + // Use our subclass so we can inject a plain-http:// protocol for the + // mock test server (the base LibCurl hardcodes https://). + 'consumer' => E2eLibCurl::class, + 'protocol' => $scheme, + 'error_handler' => function (int $code, string $message) use (&$errors): void { + $msg = "HTTP {$code}: {$message}"; + debugLog('SDK error — ' . $msg); + $errors[] = $msg; + }, + ]; + + if ($apiHost !== '') { + $options['host'] = parseHost($apiHost); + debugLog('Using host: ' . $options['host'] . ' (protocol: ' . $scheme . ')'); + } + + if (isset($config['flushAt']) && is_numeric($config['flushAt'])) { + $options['flush_at'] = (int)$config['flushAt']; + debugLog('flush_at: ' . $options['flush_at']); + } + + if (isset($config['timeout']) && is_numeric($config['timeout'])) { + $options['curl_timeout'] = (int)$config['timeout']; + debugLog('curl_timeout: ' . $options['curl_timeout']); + } + + return $options; +} + +/** + * Map an event array from the input JSON to the array accepted by the SDK. + * Only passes fields that are set in the input event. + * + * @param array $event + * @return array + */ +function buildMessage(array $event): array +{ + $fieldMap = [ + 'userId', + 'anonymousId', + 'messageId', + 'timestamp', + 'traits', + 'properties', + 'name', + 'category', + 'groupId', + 'previousId', + 'context', + 'integrations', + 'event', + ]; + + $message = []; + foreach ($fieldMap as $field) { + if (array_key_exists($field, $event)) { + $message[$field] = $event[$field]; + } + } + + return $message; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +$inputJson = parseInputArg($argv); + +if ($inputJson === null) { + outputResult(false, 0, 'Missing required --input argument'); + exit(1); +} + +$input = json_decode($inputJson, true); + +if (!is_array($input)) { + outputResult(false, 0, 'Failed to parse --input JSON: ' . json_last_error_msg()); + exit(1); +} + +$writeKey = $input['writeKey'] ?? ''; +$sequences = $input['sequences'] ?? []; + +if ($writeKey === '') { + outputResult(false, 0, 'Missing writeKey in input'); + exit(1); +} + +$errors = []; + +// Build client options (error_handler captures into $errors by reference) +$options = buildClientOptions($input, $errors); + +debugLog('Creating Segment\\Client with writeKey=' . substr($writeKey, 0, 4) . '...'); + +$client = new \Segment\Client($writeKey, $options); + +$sentBatches = 0; + +foreach ($sequences as $seqIndex => $sequence) { + $delayMs = (int)($sequence['delayMs'] ?? 0); + + if ($delayMs > 0) { + debugLog("Sequence {$seqIndex}: sleeping {$delayMs}ms"); + usleep($delayMs * 1000); + } + + $events = $sequence['events'] ?? []; + debugLog("Sequence {$seqIndex}: processing " . count($events) . ' event(s)'); + + foreach ($events as $eventIndex => $event) { + $type = $event['type'] ?? ''; + $message = buildMessage($event); + + debugLog(" [{$seqIndex}/{$eventIndex}] Enqueueing {$type}"); + + switch ($type) { + case 'track': + $client->track($message); + break; + case 'identify': + $client->identify($message); + break; + case 'page': + $client->page($message); + break; + case 'screen': + $client->screen($message); + break; + case 'alias': + $client->alias($message); + break; + case 'group': + $client->group($message); + break; + default: + $errors[] = "Unknown event type: {$type}"; + debugLog(" Unknown event type: {$type}"); + break; + } + } +} + +debugLog('Flushing...'); +$flushOk = $client->flush(); + +if ($flushOk) { + $sentBatches = 1; + debugLog('Flush succeeded'); +} else { + debugLog('Flush returned false'); + $errors[] = 'Flush failed'; +} + +$hasErrors = !empty($errors); +$success = $flushOk && !$hasErrors; + +if ($success) { + outputResult(true, $sentBatches); + exit(0); +} else { + $errorMsg = implode('; ', $errors); + outputResult(false, $sentBatches, $errorMsg); + exit(1); +} diff --git a/e2e-cli/run-e2e.sh b/e2e-cli/run-e2e.sh new file mode 100755 index 0000000..43afd98 --- /dev/null +++ b/e2e-cli/run-e2e.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# +# Run E2E tests for analytics-php +# +# Prerequisites: Node.js 18+ and PHP 7.4+ +# +# Usage: +# ./run-e2e.sh [extra args passed to run-tests.sh] +# +# Override sdk-e2e-tests location: +# E2E_TESTS_DIR=../my-e2e-tests ./run-e2e.sh +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK_ROOT="$SCRIPT_DIR/.." +E2E_DIR="${E2E_TESTS_DIR:-$SDK_ROOT/../sdk-e2e-tests}" + +echo "=== Building analytics-php e2e-cli ===" + +# Run tests +cd "$E2E_DIR" +./scripts/run-tests.sh \ + --sdk-dir "$SCRIPT_DIR" \ + --cli "php $SCRIPT_DIR/main.php" \ + "$@" diff --git a/lib/Client.php b/lib/Client.php new file mode 100644 index 0000000..bfae6ea --- /dev/null +++ b/lib/Client.php @@ -0,0 +1,279 @@ + Socket::class, + 'file' => File::class, + 'fork_curl' => ForkCurl::class, + 'lib_curl' => LibCurl::class, + ]; + // Use our socket libcurl by default + $consumer_type = $options['consumer'] ?? 'lib_curl'; + + if (!array_key_exists($consumer_type, $consumers) && class_exists($consumer_type)) { + if (!is_subclass_of($consumer_type, Consumer::class)) { + throw new SegmentException('Consumers must extend the Segment/Consumer/Consumer abstract class'); + } + // Try to resolve it by class name + $this->consumer = new $consumer_type($secret, $options); + return; + } + + $Consumer = $consumers[$consumer_type]; + + $this->consumer = new $Consumer($secret, $options); + } + + public function __destruct() + { + $this->consumer->__destruct(); + } + + /** + * Tracks a user action + * + * @param array $message + * @return bool whether the track call succeeded + */ + public function track(array $message): bool + { + $message = $this->message($message, 'properties'); + $message['type'] = 'track'; + + return $this->consumer->track($message); + } + + /** + * Add common fields to the given `message` + * + * @param array $msg + * @param string $def + * @return array + */ + + private function message(array $msg, string $def = ''): array + { + if ($def && !isset($msg[$def])) { + $msg[$def] = []; + } + if ($def && empty($msg[$def])) { + $msg[$def] = (object)$msg[$def]; + } + + if (!isset($msg['context'])) { + $msg['context'] = []; + } + $msg['context'] = array_merge($this->getDefaultContext(), $msg['context']); + + if (!isset($msg['timestamp'])) { + $msg['timestamp'] = null; + } + $msg['timestamp'] = $this->formatTime((int)$msg['timestamp']); + + if (!isset($msg['messageId'])) { + $msg['messageId'] = self::messageId(); + } + + return $msg; + } + + /** + * Add the segment.io context to the request + * @return array additional context + */ + private function getDefaultContext(): array + { + require __DIR__ . '/Version.php'; + + global $SEGMENT_VERSION; + + return [ + 'library' => [ + 'name' => 'analytics-php', + 'version' => $SEGMENT_VERSION, + 'consumer' => $this->consumer->getConsumer(), + ], + ]; + } + + /** + * Formats a timestamp by making sure it is set + * and converting it to iso8601. + * + * The timestamp can be time in seconds `time()` or `microtime(true)`. + * any other input is considered an error and the method will return a new date. + * + * Note: php's date() "u" format (for microseconds) has a bug in it + * it always shows `.000` for microseconds since `date()` only accepts + * ints, so we have to construct the date ourselves if microtime is passed. + * + * @param mixed $timestamp time in seconds (time()) or a time expression string + */ + private function formatTime($ts) + { + if (!$ts) { + $ts = time(); + } + if (filter_var($ts, FILTER_VALIDATE_INT) !== false) { + return date('c', (int)$ts); + } + + // anything else try to strtotime the date. + if (filter_var($ts, FILTER_VALIDATE_FLOAT) === false) { + if (is_string($ts)) { + return date('c', strtotime($ts)); + } + + return date('c'); + } + + // fix for floatval casting in send.php + $parts = explode('.', (string)$ts); + if (!isset($parts[1])) { + return date('c', (int)$parts[0]); + } + + $fmt = sprintf('Y-m-d\TH:i:s.%sP', $parts[1]); + + return date($fmt, (int)$parts[0]); + } + + /** + * Generate a random messageId. + * + * https://gist.github.com/dahnielson/508447#file-uuid-php-L74 + * + * @return string + */ + + private static function messageId(): string + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0xffff) + ); + } + + /** + * Tags traits about the user. + * + * @param array $message + * @return bool whether the track call succeeded + */ + public function identify(array $message): bool + { + $message = $this->message($message, 'traits'); + $message['type'] = 'identify'; + + return $this->consumer->identify($message); + } + + /** + * Tags traits about the group. + * + * @param array $message + * @return bool whether the group call succeeded + */ + public function group(array $message): bool + { + $message = $this->message($message, 'traits'); + $message['type'] = 'group'; + + return $this->consumer->group($message); + } + + /** + * Tracks a page view. + * + * @param array $message + * @return bool whether the page call succeeded + */ + public function page(array $message): bool + { + $message = $this->message($message, 'properties'); + $message['type'] = 'page'; + + return $this->consumer->page($message); + } + + /** + * Tracks a screen view. + * + * @param array $message + * @return bool whether the screen call succeeded + */ + public function screen(array $message): bool + { + $message = $this->message($message, 'properties'); + $message['type'] = 'screen'; + + return $this->consumer->screen($message); + } + + /** + * Aliases from one user id to another + * + * @param array $message + * @return bool whether the alias call succeeded + */ + public function alias(array $message): bool + { + $message = $this->message($message); + $message['type'] = 'alias'; + + return $this->consumer->alias($message); + } + + /** + * Flush any async consumers + * @return bool true if flushed successfully + */ + public function flush(): bool + { + if (method_exists($this->consumer, 'flush')) { + return $this->consumer->flush(); + } + + return true; + } + + /** + * @return Consumer + */ + public function getConsumer(): Consumer + { + return $this->consumer; + } +} diff --git a/lib/Consumer/Consumer.php b/lib/Consumer/Consumer.php new file mode 100644 index 0000000..e863e90 --- /dev/null +++ b/lib/Consumer/Consumer.php @@ -0,0 +1,112 @@ + + */ + protected array $options; + protected string $secret; + + /** + * Store our secret and options as part of this consumer + * @param string $secret + * @param array $options + */ + public function __construct(string $secret, array $options = []) + { + $this->secret = $secret; + $this->options = $options; + } + + /** + * Tracks a user action + * + * @param array $message + * @return bool whether the track call succeeded + */ + abstract public function track(array $message): bool; + + /** + * Tags traits about the user. + * + * @param array $message + * @return bool whether the identify call succeeded + */ + abstract public function identify(array $message): bool; + + /** + * Tags traits about the group. + * + * @param array $message + * @return bool whether the group call succeeded + */ + abstract public function group(array $message): bool; + + /** + * Tracks a page view. + * + * @param array $message + * @return bool whether the page call succeeded + */ + abstract public function page(array $message): bool; + + /** + * Tracks a screen view. + * + * @param array $message + * @return bool whether the group call succeeded + */ + abstract public function screen(array $message): bool; + + /** + * Aliases from one user id to another + * + * @param array $message + * @return bool whether the alias call succeeded + */ + abstract public function alias(array $message): bool; + + /** + * Getter method for consumer type. + * + * @return string + */ + public function getConsumer(): string + { + return $this->type; + } + + /** + * On an error, try and call the error handler, if debugging output to + * error_log as well. + * @param int $code + * @param string $msg + */ + protected function handleError(int $code, string $msg): void + { + if (isset($this->options['error_handler'])) { + $handler = $this->options['error_handler']; + $handler($code, $msg); + } + + if ($this->debug()) { + error_log('[Analytics][' . $this->type . '] ' . $msg); + } + } + + /** + * Check whether debug mode is enabled + * @return bool + */ + protected function debug(): bool + { + return $this->options['debug'] ?? false; + } +} diff --git a/lib/Consumer/File.php b/lib/Consumer/File.php new file mode 100644 index 0000000..278b53a --- /dev/null +++ b/lib/Consumer/File.php @@ -0,0 +1,134 @@ +file_handle = @fopen($options['filename'], 'ab'); + if ($this->file_handle === false) { + $this->handleError(13, 'Failed to open analytics.log file'); + return; + } + if (isset($options['filepermissions'])) { + chmod($options['filename'], $options['filepermissions']); + } else { + chmod($options['filename'], 0644); + } + } + + public function __destruct() + { + if ( + $this->file_handle && + get_resource_type($this->file_handle) !== 'Unknown' + ) { + fclose($this->file_handle); + } + } + + /** + * Tracks a user action + * + * @param array $message + * @return bool whether the track call succeeded + */ + public function track(array $message): bool + { + return $this->write($message); + } + + /** + * Writes the API call to a file as line-delimited json + * @param array $body post body content. + * @return bool whether the request succeeded + */ + private function write(array $body): bool + { + if (!$this->file_handle) { + return false; + } + + $content = json_encode($body); + $content .= "\n"; + + return fwrite($this->file_handle, $content) === strlen($content); + } + + /** + * Tags traits about the user. + * + * @param array $message + * @return bool whether the identify call succeeded + */ + public function identify(array $message): bool + { + return $this->write($message); + } + + /** + * Tags traits about the group. + * + * @param array $message + * @return bool whether the group call succeeded + */ + public function group(array $message): bool + { + return $this->write($message); + } + + /** + * Tracks a page view. + * + * @param array $message + * @return bool whether the page call succeeded + */ + public function page(array $message): bool + { + return $this->write($message); + } + + /** + * Tracks a screen view. + * + * @param array $message + * @return bool whether the screen call succeeded + */ + public function screen(array $message): bool + { + return $this->write($message); + } + + /** + * Aliases from one user id to another + * + * @param array $message + * @return bool whether the alias call succeeded + */ + public function alias(array $message): bool + { + return $this->write($message); + } +} diff --git a/lib/Consumer/ForkCurl.php b/lib/Consumer/ForkCurl.php new file mode 100644 index 0000000..b38528c --- /dev/null +++ b/lib/Consumer/ForkCurl.php @@ -0,0 +1,89 @@ +payload($messages); + $payload = json_encode($body); + + // Escape for shell usage. + $payload = escapeshellarg($payload); + $secret = escapeshellarg($this->secret); + + if ($this->host) { + $host = $this->host; + } else { + $host = 'api.segment.io'; + } + $path = '/v1/batch'; + $url = $this->protocol . $host . $path; + + $cmd = "curl -u $secret: -X POST -H 'Content-Type: application/json'"; + + $tmpfname = ''; + if ($this->compress_request) { + // Compress request to file + $tmpfname = tempnam('/tmp', 'forkcurl_'); + $cmd2 = 'echo ' . $payload . ' | gzip > ' . $tmpfname; + exec($cmd2, $output, $exit); + + if ($exit !== 0) { + $output = implode(PHP_EOL, $output); + $this->handleError($exit, $output); + return false; + } + + $cmd .= " -H 'Content-Encoding: gzip'"; + + $cmd .= " --data-binary '@" . $tmpfname . "'"; + } else { + $cmd .= ' -d ' . $payload; + } + + $cmd .= ' ' . escapeshellarg($url); + + // Verify payload size is below 512KB + if (strlen($payload) >= 500 * 1024) { + $msg = 'Payload size is larger than 512KB'; + error_log('[Analytics][' . $this->type . '] ' . $msg); + + return false; + } + + // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. + $library = $messages[0]['context']['library']; + $libName = $library['name']; + $libVersion = $library['version']; + $cmd .= ' -H ' . escapeshellarg("User-Agent: $libName/$libVersion"); + + if (!$this->debug()) { + $cmd .= ' > /dev/null 2>&1 &'; + } + + exec($cmd, $output, $exit); + + if ($exit !== 0) { + $output = implode(PHP_EOL, $output); + $this->handleError($exit, $output); + } + + if ($tmpfname !== '') { + unlink($tmpfname); + } + + return $exit === 0; + } +} diff --git a/lib/Consumer/LibCurl.php b/lib/Consumer/LibCurl.php new file mode 100644 index 0000000..405467b --- /dev/null +++ b/lib/Consumer/LibCurl.php @@ -0,0 +1,100 @@ +payload($messages); + $payload = json_encode($body); + $secret = $this->secret; + + if ($this->compress_request) { + $payload = gzencode($payload); + } + + if ($this->host) { + $host = $this->host; + } else { + $host = 'api.segment.io'; + } + $path = '/v1/batch'; + $url = $this->protocol . $host . $path; + + $backoff = 100; // Set initial waiting time to 100ms + + while ($backoff < $this->maximum_backoff_duration) { + // open connection + $ch = curl_init(); + + // set the url, number of POST vars, POST data + curl_setopt($ch, CURLOPT_USERPWD, $secret . ':'); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + curl_setopt($ch, CURLOPT_TIMEOUT, $this->curl_timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->curl_connecttimeout); + + // set variables for headers + $header = []; + $header[] = 'Content-Type: application/json'; + + if ($this->compress_request) { + $header[] = 'Content-Encoding: gzip'; + } + + // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. + $library = $messages[0]['context']['library']; + $libName = $library['name']; + $libVersion = $library['version']; + $header[] = "User-Agent: $libName/$libVersion"; + + curl_setopt($ch, CURLOPT_HTTPHEADER, $header); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + // retry failed requests just once to diminish impact on performance + $responseContent = curl_exec($ch); + + $err = curl_error($ch); + if ($err) { + $this->handleError(curl_errno($ch), $err); + return false; + } + + $responseCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + + //close connection + curl_close($ch); + + if ($responseCode !== 200) { + // log error + $this->handleError($responseCode, $responseContent); + + if (($responseCode >= 500 && $responseCode <= 600) || $responseCode === 429) { + // If status code is greater than 500 and less than 600, it indicates server error + // Error code 429 indicates rate limited. + // Retry uploading in these cases. + usleep($backoff * 1000); + $backoff *= 2; + } elseif ($responseCode >= 400) { + break; + } + } else { + break; // no error + } + } + + return true; + } +} diff --git a/lib/Consumer/QueueConsumer.php b/lib/Consumer/QueueConsumer.php new file mode 100644 index 0000000..60f7e43 --- /dev/null +++ b/lib/Consumer/QueueConsumer.php @@ -0,0 +1,241 @@ + + */ + protected array $queue; + protected int $max_queue_size = 10000; + protected int $max_queue_size_bytes = 33554432; //32M + protected int $flush_at = 100; + protected int $max_batch_size_bytes = 512000; //500kb + protected int $max_item_size_bytes = 32000; // 32kb + protected int $maximum_backoff_duration = 10000; // Set maximum waiting limit to 10s + protected string $host = ''; + protected bool $compress_request = false; + protected int $flush_interval_in_mills = 10000; //frequency in milliseconds to send data, default 10 + protected int $curl_timeout = 0; // by default this is infinite + protected int $curl_connecttimeout = 300; + + /** + * Store our secret and options as part of this consumer + * @param string $secret + * @param array $options + */ + public function __construct(string $secret, array $options = []) + { + parent::__construct($secret, $options); + + if (isset($options['max_queue_size'])) { + $this->max_queue_size = $options['max_queue_size']; + } + + if (isset($options['batch_size'])) { + if ($options['batch_size'] < 1) { + $msg = 'Batch Size must not be less than 1'; + error_log('[Analytics][' . $this->type . '] ' . $msg); + } else { + $msg = 'WARNING: batch_size option to be deprecated soon, please use new option flush_at'; + error_log('[Analytics][' . $this->type . '] ' . $msg); + $this->flush_at = $options['batch_size']; + } + } + + if (isset($options['flush_at'])) { + if ($options['flush_at'] < 1) { + $msg = 'Flush at Size must not be less than 1'; + error_log('[Analytics][' . $this->type . '] ' . $msg); + } else { + $this->flush_at = $options['flush_at']; + } + } + + if (isset($options['host'])) { + $this->host = $options['host']; + } + + if (isset($options['compress_request'])) { + $this->compress_request = (bool)$options['compress_request']; + } + + if (isset($options['flush_interval'])) { + if ($options['flush_interval'] < 1000) { + $msg = 'Flush interval must not be less than 1 second'; + error_log('[Analytics][' . $this->type . '] ' . $msg); + } else { + $this->flush_interval_in_mills = $options['flush_interval']; + } + } + + if (isset($options['curl_timeout'])) { + $this->curl_timeout = $options['curl_timeout']; + } + + if (isset($options['curl_connecttimeout'])) { + $this->curl_connecttimeout = $options['curl_connecttimeout']; + } + + $this->queue = []; + } + + public function __destruct() + { + // Flush our queue on destruction + $this->flush(); + } + + /** + * Flushes our queue of messages by batching them to the server + */ + public function flush(): bool + { + $count = count($this->queue); + $success = true; + + while ($count > 0 && $success) { + $batch = array_splice($this->queue, 0, min($this->flush_at, $count)); + + if (mb_strlen(serialize($batch), '8bit') >= $this->max_batch_size_bytes) { + $msg = 'Batch size is larger than 500KB'; + error_log('[Analytics][' . $this->type . '] ' . $msg); + + return false; + } + + $success = $this->flushBatch($batch); + + $count = count($this->queue); + + if ($count > 0) { + usleep($this->flush_interval_in_mills * 1000); + } + } + + return $success; + } + + /** + * Tracks a user action + * + * @param array $message + * @return bool whether the track call succeeded + */ + public function track(array $message): bool + { + return $this->enqueue($message); + } + + /** + * Adds an item to our queue. + * @param mixed $item + * @return bool whether call has succeeded + */ + protected function enqueue($item): bool + { + $count = count($this->queue); + + if ($count > $this->max_queue_size) { + return false; + } + + if (mb_strlen(serialize($this->queue), '8bit') >= $this->max_queue_size_bytes) { + $msg = 'Queue size is larger than 32MB'; + error_log('[Analytics][' . $this->type . '] ' . $msg); + + return false; + } + + if (mb_strlen(json_encode($item), '8bit') >= $this->max_item_size_bytes) { + $msg = 'Item size is larger than 32KB'; + error_log('[Analytics][' . $this->type . '] ' . $msg); + + return false; + } + + $count = array_push($this->queue, $item); + + if ($count >= $this->flush_at) { + return $this->flush(); + } + + return true; + } + + /** + * Tags traits about the user. + * + * @param array $message + * @return bool whether the identify call succeeded + */ + public function identify(array $message): bool + { + return $this->enqueue($message); + } + + /** + * Tags traits about the group. + * + * @param array $message + * @return bool whether the group call succeeded + */ + public function group(array $message): bool + { + return $this->enqueue($message); + } + + /** + * Tracks a page view. + * + * @param array $message + * @return bool whether the page call succeeded + */ + public function page(array $message): bool + { + return $this->enqueue($message); + } + + /** + * Tracks a screen view. + * + * @param array $message + * @return bool whether the screen call succeeded + */ + public function screen(array $message): bool + { + return $this->enqueue($message); + } + + /** + * Aliases from one user id to another + * + * @param array $message + * @return bool whether the alias call succeeded + */ + public function alias(array $message): bool + { + return $this->enqueue($message); + } + + /** + * Given a batch of messages the method returns + * a valid payload. + * + * @param array $batch + * @return array + */ + protected function payload(array $batch): array + { + return [ + 'batch' => $batch, + 'sentAt' => date('c'), + ]; + } +} diff --git a/lib/Consumer/Socket.php b/lib/Consumer/Socket.php new file mode 100644 index 0000000..c339575 --- /dev/null +++ b/lib/Consumer/Socket.php @@ -0,0 +1,225 @@ +createSocket(); + + if (!$socket) { + return false; + } + + $payload = $this->payload($batch); + $payload = json_encode($payload); + + $body = $this->createBody($this->options['host'], $payload); + if ($body === false) { + return false; + } + + return $this->makeRequest($socket, $body); + } + + /** + * Open a connection to the target host. + * + * @return false|resource + */ + private function createSocket() + { + if ($this->socket_failed) { + return false; + } + + $protocol = $this->options['tls'] ? 'tls' : 'ssl'; + $host = $this->options['host']; + $port = 443; + $timeout = $this->options['timeout']; + + // Open our socket to the API Server. + $socket = @pfsockopen( + $protocol . '://' . $host, + $port, + $errno, + $errstr, + $timeout + ); + + // If we couldn't open the socket, handle the error. + if ($socket === false) { + $this->handleError($errno, $errstr); + $this->socket_failed = true; + } + + return $socket; + } + + /** + * Create the request body. + * + * @param string $host + * @param string $content + * @return string body + */ + private function createBody(string $host, string $content) + { + $req = "POST /v1/batch HTTP/1.1\r\n"; + $req .= 'Host: ' . $host . "\r\n"; + $req .= "Content-Type: application/json\r\n"; + $req .= 'Authorization: Basic ' . base64_encode($this->secret . ':') . "\r\n"; + $req .= "Accept: application/json\r\n"; + + // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. + $content_json = json_decode($content, true); + $library = $content_json['batch'][0]['context']['library']; + $libName = $library['name']; + $libVersion = $library['version']; + $req .= "User-Agent: $libName/$libVersion\r\n"; + + // Compress content if compress_request is true + if ($this->compress_request) { + $content = gzencode($content); + + $req .= "Content-Encoding: gzip\r\n"; + } + + $req .= 'Content-length: ' . strlen($content) . "\r\n"; + $req .= "\r\n"; + $req .= $content; + + // Verify payload size is below 512KB + if (strlen($req) >= 500 * 1024) { + $msg = 'Payload size is larger than 512KB'; + /** @noinspection ForgottenDebugOutputInspection */ + error_log('[Analytics][' . $this->type . '] ' . $msg); + + return false; + } + + return $req; + } + + /** + * Attempt to write the request to the socket, wait for response if debug + * mode is enabled. + * + * @param resource|false $socket the handle for the socket + * @param string $req request body + * @return bool + */ + private function makeRequest($socket, string $req): bool + { + $bytes_written = 0; + $bytes_total = strlen($req); + $closed = false; + $success = true; + + // Retries with exponential backoff until success + $backoff = 100; // Set initial waiting time to 100ms + + while (true) { + // Send request to server + while (!$closed && $bytes_written < $bytes_total) { + $written = @fwrite($socket, substr($req, $bytes_written)); + if ($written === false) { + $this->handleError(13, 'Failed to write to socket.'); + $closed = true; + } else { + $bytes_written += $written; + } + } + + // Get response for request + $statusCode = 0; + + if (!$closed) { + $res = self::parseResponse(fread($socket, 2048)); + $statusCode = (int)$res['status']; + } + fclose($socket); + + // If status code is 200, return true + if ($statusCode === 200) { + return true; + } + + // If status code is greater than 500 and less than 600, it indicates server error + // Error code 429 indicates rate limited. + // Retry uploading in these cases. + if (($statusCode >= 500 && $statusCode <= 600) || $statusCode === 429 || $statusCode === 0) { + if ($backoff >= $this->maximum_backoff_duration) { + break; + } + + usleep($backoff * 1000); + } elseif ($statusCode >= 400) { + if ($this->debug()) { + $this->handleError($res['status'], $res['message']); + } + + break; + } + + // Retry uploading... + $backoff *= 2; + $socket = $this->createSocket(); + } + + return $success; + } + + /** + * Parse our response from the server, check header and body. + * @param string $res + * @return array + * string $status HTTP code, e.g. "200" + * string $message JSON response from the api + */ + private static function parseResponse(string $res): array + { + [$first,] = explode("\n", $res, 2); + + // Response comes back as HTTP/1.1 200 OK + // Final line contains HTTP response. + [, $status, $message] = explode(' ', $first, 3); + + return [ + 'status' => $status ?? null, + 'message' => $message, + ]; + } +} diff --git a/lib/Segment.php b/lib/Segment.php index 2e1aee0..226a1d5 100644 --- a/lib/Segment.php +++ b/lib/Segment.php @@ -1,154 +1,164 @@ track($message); - } - - /** - * Tags traits about the user. - * - * @param array $message - * @return boolean whether the identify call succeeded - */ - public static function identify(array $message) { - self::checkClient(); - $message["type"] = "identify"; - self::validate($message, "identify"); - - return self::$client->identify($message); - } - - /** - * Tags traits about the group. - * - * @param array $message - * @return boolean whether the group call succeeded - */ - public static function group(array $message) { - self::checkClient(); - $groupId = !empty($message["groupId"]); - self::assert($groupId, "Segment::group() expects groupId"); - self::validate($message, "group"); - - return self::$client->group($message); - } - - /** - * Tracks a page view - * - * @param array $message - * @return boolean whether the page call succeeded - */ - public static function page(array $message) { - self::checkClient(); - self::validate($message, "page"); - - return self::$client->page($message); - } - - /** - * Tracks a screen view - * - * @param array $message - * @return boolean whether the screen call succeeded - */ - public static function screen(array $message) { - self::checkClient(); - self::validate($message, "screen"); - - return self::$client->screen($message); - } - - /** - * Aliases the user id from a temporary id to a permanent one - * - * @param array $from user id to alias from - * @return boolean whether the alias call succeeded - */ - public static function alias(array $message) { - self::checkClient(); - $userId = !empty($message["userId"]); - $previousId = !empty($message["previousId"]); - self::assert($userId && $previousId, "Segment::alias() requires both userId and previousId"); - - return self::$client->alias($message); - } - - /** - * Validate common properties. - * - * @param array $msg - * @param string $type - */ - public static function validate($msg, $type){ - $userId = !empty($msg["userId"]); - $anonId = !empty($msg["anonymousId"]); - self::assert($userId || $anonId, "Segment::${type}() requires userId or anonymousId"); - } - - /** - * Flush the client - */ - - public static function flush(){ - self::checkClient(); - - return self::$client->flush(); - } - - /** - * Check the client. - * - * @throws Exception - */ - private static function checkClient(){ - if (null != self::$client) { - return; +declare(strict_types=1); + +namespace Segment; + +class Segment +{ + private static ?Client $client = null; + + /** + * Initializes the default client to use. Uses the libcurl consumer by default. + * + * @param string $secret your project's secret key + * @param array $options passed straight to the client + */ + public static function init(string $secret, array $options = []): void + { + self::assert($secret, 'Segment::init() requires secret'); + self::$client = new Client($secret, $options); } - throw new Exception("Segment::init() must be called before any other tracking method."); - } - - /** - * Assert `value` or throw. - * - * @param array $value - * @param string $msg - * @throws Exception - */ - private static function assert($value, $msg) { - if (!$value) { - throw new Exception($msg); + /** + * Assert `value` or throw. + * + * @param mixed $value + * @param string $msg + * @throws SegmentException + */ + private static function assert($value, string $msg): void + { + if (empty($value)) { + throw new SegmentException($msg); + } + } + + /** + * Tracks a user action + * + * @param array $message + * @return bool whether the track call succeeded + */ + public static function track(array $message): bool + { + self::checkClient(); + $event = !empty($message['event']); + self::assert($event, 'Segment::track() expects an event'); + self::validate($message, 'track'); + + return self::$client->track($message); + } + + /** + * Check the client. + * + * @throws SegmentException + */ + private static function checkClient(): void + { + if (self::$client !== null) { + return; + } + + throw new SegmentException('Segment::init() must be called before any other tracking method.'); } - } -} -if (!function_exists('json_encode')) { - throw new Exception('Segment needs the JSON PHP extension.'); + /** + * Validate common properties. + * + * @param array $message + * @param string $type + */ + public static function validate(array $message, string $type): void + { + $userId = (array_key_exists('userId', $message) && (string)$message['userId'] !== ''); + $anonId = !empty($message['anonymousId']); + self::assert($userId || $anonId, "Segment::$type() requires userId or anonymousId"); + } + + /** + * Tags traits about the user. + * + * @param array $message + * @return bool whether the call succeeded + */ + public static function identify(array $message): bool + { + self::checkClient(); + $message['type'] = 'identify'; + self::validate($message, 'identify'); + + return self::$client->identify($message); + } + + /** + * Tags traits about the group. + * + * @param array $message + * @return bool whether the group call succeeded + */ + public static function group(array $message): bool + { + self::checkClient(); + $groupId = !empty($message['groupId']); + self::assert($groupId, 'Segment::group() expects a groupId'); + self::validate($message, 'group'); + + return self::$client->group($message); + } + + /** + * Tracks a page view + * + * @param array $message + * @return bool whether the page call succeeded + */ + public static function page(array $message): bool + { + self::checkClient(); + self::validate($message, 'page'); + + return self::$client->page($message); + } + + /** + * Tracks a screen view + * + * @param array $message + * @return bool whether the screen call succeeded + */ + public static function screen(array $message): bool + { + self::checkClient(); + self::validate($message, 'screen'); + + return self::$client->screen($message); + } + + /** + * Aliases the user id from a temporary id to a permanent one + * + * @param array $message + * @return bool whether the alias call succeeded + */ + public static function alias(array $message): bool + { + self::checkClient(); + $userId = (array_key_exists('userId', $message) && (string)$message['userId'] !== ''); + $previousId = (array_key_exists('previousId', $message) && (string)$message['previousId'] !== ''); + self::assert($userId && $previousId, 'Segment::alias() requires both userId and previousId'); + + return self::$client->alias($message); + } + + /** + * Flush the client + */ + public static function flush(): bool + { + self::checkClient(); + + return self::$client->flush(); + } } diff --git a/lib/Segment/Client.php b/lib/Segment/Client.php deleted file mode 100644 index 9ac6be5..0000000 --- a/lib/Segment/Client.php +++ /dev/null @@ -1,245 +0,0 @@ - "Segment_Consumer_Socket", - "file" => "Segment_Consumer_File", - "fork_curl" => "Segment_Consumer_ForkCurl", - "lib_curl" => "Segment_Consumer_LibCurl" - ); - - // Use our socket libcurl by default - $consumer_type = isset($options["consumer"]) ? $options["consumer"] : - "lib_curl"; - - $Consumer = $consumers[$consumer_type]; - - $this->consumer = new $Consumer($secret, $options); - } - - public function __destruct() { - $this->consumer->__destruct(); - } - - /** - * Tracks a user action - * - * @param array $message - * @return [boolean] whether the track call succeeded - */ - public function track(array $message) { - $message = $this->message($message, "properties"); - $message["type"] = "track"; - - return $this->consumer->track($message); - } - - /** - * Tags traits about the user. - * - * @param [array] $message - * @return [boolean] whether the track call succeeded - */ - public function identify(array $message) { - $message = $this->message($message, "traits"); - $message["type"] = "identify"; - - return $this->consumer->identify($message); - } - - /** - * Tags traits about the group. - * - * @param [array] $message - * @return [boolean] whether the group call succeeded - */ - public function group(array $message) { - $message = $this->message($message, "traits"); - $message["type"] = "group"; - - return $this->consumer->group($message); - } - - /** - * Tracks a page view. - * - * @param [array] $message - * @return [boolean] whether the page call succeeded - */ - public function page(array $message) { - $message = $this->message($message, "properties"); - $message["type"] = "page"; - - return $this->consumer->page($message); - } - - /** - * Tracks a screen view. - * - * @param [array] $message - * @return [boolean] whether the screen call succeeded - */ - public function screen(array $message) { - $message = $this->message($message, "properties"); - $message["type"] = "screen"; - - return $this->consumer->screen($message); - } - - /** - * Aliases from one user id to another - * - * @param array $message - * @return boolean whether the alias call succeeded - */ - public function alias(array $message) { - $message = $this->message($message); - $message["type"] = "alias"; - - return $this->consumer->alias($message); - } - - /** - * Flush any async consumers - * @return boolean true if flushed successfully - */ - public function flush() { - if (method_exists($this->consumer, 'flush')) { - return $this->consumer->flush(); - } - - return true; - } - - /** - * Formats a timestamp by making sure it is set - * and converting it to iso8601. - * - * The timestamp can be time in seconds `time()` or `microseconds(true)`. - * any other input is considered an error and the method will return a new date. - * - * Note: php's date() "u" format (for microseconds) has a bug in it - * it always shows `.000` for microseconds since `date()` only accepts - * ints, so we have to construct the date ourselves if microtime is passed. - * - * @param ts $timestamp - time in seconds (time()) - */ - private function formatTime($ts) { - // time() - if (null == $ts || !$ts) { - $ts = time(); - } - if (false !== filter_var($ts, FILTER_VALIDATE_INT)) { - return date("c", (int) $ts); - } - - // anything else try to strtotime the date. - if (false === filter_var($ts, FILTER_VALIDATE_FLOAT)) { - if (is_string($ts)) { - return date("c", strtotime($ts)); - } - - return date("c"); - } - - // fix for floatval casting in send.php - $parts = explode(".", (string)$ts); - if (!isset($parts[1])) { - return date("c", (int)$parts[0]); - } - - // microtime(true) - $sec = (int)$parts[0]; - $usec = (int)$parts[1]; - $fmt = sprintf("Y-m-d\\TH:i:s%sP", $usec); - - return date($fmt, (int)$sec); - } - - /** - * Add common fields to the given `message` - * - * @param array $msg - * @param string $def - * @return array - */ - - private function message($msg, $def = ""){ - if ($def && !isset($msg[$def])) { - $msg[$def] = array(); - } - if ($def && empty($msg[$def])) { - $msg[$def] = (object)$msg[$def]; - } - - if (!isset($msg["context"])) { - $msg["context"] = array(); - } - $msg["context"] = array_merge($this->getDefaultContext(), $msg["context"]); - - if (!isset($msg["timestamp"])) { - $msg["timestamp"] = null; - } - $msg["timestamp"] = $this->formatTime($msg["timestamp"]); - $msg["messageId"] = self::messageId(); - - return $msg; - } - - /** - * Generate a random messageId. - * - * https://gist.github.com/dahnielson/508447#file-uuid-php-L74 - * - * @return string - */ - - private static function messageId(){ - return sprintf("%04x%04x-%04x-%04x-%04x-%04x%04x%04x", - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0x0fff) | 0x4000, - mt_rand(0, 0x3fff) | 0x8000, - mt_rand(0, 0xffff), - mt_rand(0, 0xffff), - mt_rand(0, 0xffff) - ); - } - - /** - * Add the segment.io context to the request - * @return array additional context - */ - private function getDefaultContext() { - global $SEGMENT_VERSION; - - return array( - "library" => array( - "name" => "analytics-php", - "version" => $SEGMENT_VERSION, - "consumer" => $this->consumer->getConsumer() - ) - ); - } -} diff --git a/lib/Segment/Consumer.php b/lib/Segment/Consumer.php deleted file mode 100644 index b893ed3..0000000 --- a/lib/Segment/Consumer.php +++ /dev/null @@ -1,100 +0,0 @@ -secret = $secret; - $this->options = $options; - } - - /** - * Tracks a user action - * - * @param array $message - * @return boolean whether the track call succeeded - */ - abstract public function track(array $message); - - /** - * Tags traits about the user. - * - * @param array $message - * @return boolean whether the identify call succeeded - */ - abstract public function identify(array $message); - - /** - * Tags traits about the group. - * - * @param array $message - * @return boolean whether the group call succeeded - */ - abstract public function group(array $message); - - /** - * Tracks a page view. - * - * @param array $message - * @return boolean whether the page call succeeded - */ - abstract public function page(array $message); - - /** - * Tracks a screen view. - * - * @param array $message - * @return boolean whether the group call succeeded - */ - abstract public function screen(array $message); - - /** - * Aliases from one user id to another - * - * @param array $message - * @return boolean whether the alias call succeeded - */ - abstract public function alias(array $message); - - /** - * Check whether debug mode is enabled - * @return boolean - */ - protected function debug() { - return isset($this->options["debug"]) ? $this->options["debug"] : false; - } - - /** - * Check whether we should connect to the API using SSL. This is enabled by - * default with connections which make batching requests. For connections - * which can save on round-trip times, you may disable it. - * @return boolean - */ - protected function ssl() { - return isset($this->options["ssl"]) ? $this->options["ssl"] : true; - } - - /** - * On an error, try and call the error handler, if debugging output to - * error_log as well. - * @param string $code - * @param string $msg - */ - protected function handleError($code, $msg) { - if (isset($this->options['error_handler'])) { - $handler = $this->options['error_handler']; - $handler($code, $msg); - } - - if ($this->debug()) { - error_log("[Analytics][" . $this->type . "] " . $msg); - } - } -} diff --git a/lib/Segment/Consumer/File.php b/lib/Segment/Consumer/File.php deleted file mode 100644 index d59d71c..0000000 --- a/lib/Segment/Consumer/File.php +++ /dev/null @@ -1,116 +0,0 @@ -file_handle = fopen($options["filename"], "a"); - chmod($options["filename"], 0777); - } catch (Exception $e) { - $this->handleError($e->getCode(), $e->getMessage()); - } - } - - public function __destruct() { - if ($this->file_handle && - "Unknown" != get_resource_type($this->file_handle)) { - fclose($this->file_handle); - } - } - - //define getter method for consumer type - public function getConsumer() { - return $this->type; - } - - /** - * Tracks a user action - * - * @param array $message - * @return [boolean] whether the track call succeeded - */ - public function track(array $message) { - return $this->write($message); - } - - /** - * Tags traits about the user. - * - * @param array $message - * @return [boolean] whether the identify call succeeded - */ - public function identify(array $message) { - return $this->write($message); - } - - /** - * Tags traits about the group. - * - * @param array $message - * @return [boolean] whether the group call succeeded - */ - public function group(array $message) { - return $this->write($message); - } - - /** - * Tracks a page view. - * - * @param array $message - * @return [boolean] whether the page call succeeded - */ - public function page(array $message) { - return $this->write($message); - } - - /** - * Tracks a screen view. - * - * @param array $message - * @return [boolean] whether the screen call succeeded - */ - public function screen(array $message) { - return $this->write($message); - } - - /** - * Aliases from one user id to another - * - * @param array $message - * @return boolean whether the alias call succeeded - */ - public function alias(array $message) { - return $this->write($message); - } - - /** - * Writes the API call to a file as line-delimited json - * @param [array] $body post body content. - * @return [boolean] whether the request succeeded - */ - private function write($body) { - if (!$this->file_handle) { - return false; - } - - $content = json_encode($body); - $content.= "\n"; - - return fwrite($this->file_handle, $content) == strlen($content); - } -} diff --git a/lib/Segment/Consumer/ForkCurl.php b/lib/Segment/Consumer/ForkCurl.php deleted file mode 100644 index 8251570..0000000 --- a/lib/Segment/Consumer/ForkCurl.php +++ /dev/null @@ -1,102 +0,0 @@ -type; - } - - /** - * Make an async request to our API. Fork a curl process, immediately send - * to the API. If debug is enabled, we wait for the response. - * @param array $messages array of all the messages to send - * @return boolean whether the request succeeded - */ - public function flushBatch($messages) { - $body = $this->payload($messages); - $payload = json_encode($body); - - // Escape for shell usage. - $payload = escapeshellarg($payload); - $secret = $this->secret; - - $protocol = $this->ssl() ? "https://" : "http://"; - if ($this->host) { - $host = $this->host; - } else { - $host = "api.segment.io"; - } - $path = "/v1/import"; - $url = $protocol . $host . $path; - - $cmd = "curl -u ${secret}: -X POST -H 'Content-Type: application/json'"; - - $tmpfname = ""; - if ($this->compress_request) { - // Compress request to file - $tmpfname = tempnam("/tmp", "forkcurl_"); - $cmd2 = "echo " . $payload . " | gzip > " . $tmpfname; - exec($cmd2, $output, $exit); - - if (0 != $exit) { - $this->handleError($exit, $output); - return false; - } - - $cmd.= " -H 'Content-Encoding: gzip'"; - - $cmd.= " --data-binary '@" . $tmpfname . "'"; - } else { - $cmd.= " -d " . $payload; - } - - $cmd.= " '" . $url . "'"; - - // Verify message size is below than 32KB - if (strlen($payload) >= 32 * 1024) { - if ($this->debug()) { - $msg = "Message size is larger than 32KB"; - error_log("[Analytics][" . $this->type . "] " . $msg); - } - - return false; - } - - // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. - $library = $messages[0]['context']['library']; - $libName = $library['name']; - $libVersion = $library['version']; - $cmd.= " -H 'User-Agent: ${libName}/${libVersion}'"; - - if (!$this->debug()) { - $cmd .= " > /dev/null 2>&1 &"; - } - - exec($cmd, $output, $exit); - - if (0 != $exit) { - $this->handleError($exit, $output); - } - - if ($tmpfname != "") { - unlink($tmpfname); - } - - return 0 == $exit; - } -} diff --git a/lib/Segment/Consumer/LibCurl.php b/lib/Segment/Consumer/LibCurl.php deleted file mode 100644 index 842a803..0000000 --- a/lib/Segment/Consumer/LibCurl.php +++ /dev/null @@ -1,123 +0,0 @@ -type; - } - - /** - * Make a sync request to our API. If debug is - * enabled, we wait for the response - * and retry once to diminish impact on performance. - * @param array $messages array of all the messages to send - * @return boolean whether the request succeeded - */ - public function flushBatch($messages) { - $body = $this->payload($messages); - $payload = json_encode($body); - $secret = $this->secret; - - // Verify message size is below than 32KB - if (strlen($payload) >= 32 * 1024) { - if ($this->debug()) { - $msg = "Message size is larger than 32KB"; - error_log("[Analytics][" . $this->type . "] " . $msg); - } - - return false; - } - - if ($this->compress_request) { - $payload = gzencode($payload); - } - - $protocol = $this->ssl() ? "https://" : "http://"; - if ($this->host) { - $host = $this->host; - } else { - $host = "api.segment.io"; - } - $path = "/v1/import"; - $url = $protocol . $host . $path; - - $backoff = 100; // Set initial waiting time to 100ms - - while ($backoff < $this->maximum_backoff_duration) { - $start_time = microtime(true); - - // open connection - $ch = curl_init(); - - // set the url, number of POST vars, POST data - curl_setopt($ch, CURLOPT_USERPWD, $secret . ':'); - curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); - - // set variables for headers - $header = array(); - $header[] = 'Content-Type: application/json'; - - if ($this->compress_request) { - $header[] = 'Content-Encoding: gzip'; - } - - // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. - $library = $messages[0]['context']['library']; - $libName = $library['name']; - $libVersion = $library['version']; - $header[] = "User-Agent: ${libName}/${libVersion}"; - - curl_setopt($ch, CURLOPT_HTTPHEADER, $header); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - // retry failed requests just once to diminish impact on performance - $httpResponse = $this->executePost($ch); - - //close connection - curl_close($ch); - - $elapsed_time = microtime(true) - $start_time; - - if (200 != $httpResponse) { - // log error - $this->handleError($ch, $httpResponse); - - if (($httpResponse >= 500 && $httpResponse <= 600) || 429 == $httpResponse) { - // If status code is greater than 500 and less than 600, it indicates server error - // Error code 429 indicates rate limited. - // Retry uploading in these cases. - usleep($backoff * 1000); - $backoff *= 2; - } elseif ($httpResponse >= 400) { - break; - } - } else { - break; // no error - } - } - - return $httpResponse; - } - - public function executePost($ch) { - curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - - return $httpCode; - } -} diff --git a/lib/Segment/Consumer/Socket.php b/lib/Segment/Consumer/Socket.php deleted file mode 100644 index 2bec79b..0000000 --- a/lib/Segment/Consumer/Socket.php +++ /dev/null @@ -1,227 +0,0 @@ -type; - } - - public function flushBatch($batch) { - $socket = $this->createSocket(); - - if (!$socket) { - return; - } - - $payload = $this->payload($batch); - $payload = json_encode($payload); - - $body = $this->createBody($this->options["host"], $payload); - if (false === $body) { - return false; - } - - return $this->makeRequest($socket, $body); - } - - private function createSocket() { - if ($this->socket_failed) { - return false; - } - - $protocol = $this->ssl() ? "ssl" : "tcp"; - $host = $this->options["host"]; - $port = $this->ssl() ? 443 : 80; - $timeout = $this->options["timeout"]; - - try { - // Open our socket to the API Server. - // Since we're try catch'ing prevent PHP logs. - $socket = @pfsockopen( - $protocol . "://" . $host, - $port, - $errno, - $errstr, - $timeout - ); - - // If we couldn't open the socket, handle the error. - if (false === $socket) { - $this->handleError($errno, $errstr); - $this->socket_failed = true; - - return false; - } - - return $socket; - } catch (Exception $e) { - $this->handleError($e->getCode(), $e->getMessage()); - $this->socket_failed = true; - - return false; - } - } - - /** - * Attempt to write the request to the socket, wait for response if debug - * mode is enabled. - * @param stream $socket the handle for the socket - * @param string $req request body - * @param boolean $retry - * @return boolean $success - */ - private function makeRequest($socket, $req, $retry = true) { - $bytes_written = 0; - $bytes_total = strlen($req); - $closed = false; - - // Retries with exponential backoff until success - $backoff = 100; // Set initial waiting time to 100ms - - while (true) { - // Send request to server - while (!$closed && $bytes_written < $bytes_total) { - try { - // Since we're try catch'ing prevent PHP logs. - $written = @fwrite($socket, substr($req, $bytes_written)); - } catch (Exception $e) { - $this->handleError($e->getCode(), $e->getMessage()); - $closed = true; - } - if (!isset($written) || !$written) { - $closed = true; - } else { - $bytes_written += $written; - } - } - - // Get response for request - $statusCode = 0; - $errorMessage = ""; - - if (!$closed) { - $res = $this->parseResponse(fread($socket, 2048)); - $statusCode = (int)$res["status"]; - $errorMessage = $res["message"]; - } - fclose($socket); - - // If status code is 200, return true - if (200 == $statusCode) { - return true; - } - - // If status code is greater than 500 and less than 600, it indicates server error - // Error code 429 indicates rate limited. - // Retry uploading in these cases. - if (($statusCode >= 500 && $statusCode <= 600) || 429 == $statusCode || 0 == $statusCode) { - if ($backoff >= $this->maximum_backoff_duration) { - break; - } - - usleep($backoff * 1000); - } elseif ($statusCode >= 400) { - if ($this->debug()) { - $this->handleError($res["status"], $res["message"]); - } - - break; - } - - // Retry uploading... - $backoff *= 2; - $socket = $this->createSocket(); - } - - return $success; - } - - /** - * Create the body to send as the post request. - * @param string $host - * @param string $content - * @return string body - */ - private function createBody($host, $content) { - $req = ""; - $req.= "POST /v1/import HTTP/1.1\r\n"; - $req.= "Host: " . $host . "\r\n"; - $req.= "Content-Type: application/json\r\n"; - $req.= "Authorization: Basic " . base64_encode($this->secret . ":") . "\r\n"; - $req.= "Accept: application/json\r\n"; - - // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. - $content_json = json_decode($content, true); - $library = $content_json['batch'][0]['context']['library']; - $libName = $library['name']; - $libVersion = $library['version']; - $req.= "User-Agent: ${libName}/${libVersion}\r\n"; - - // Compress content if compress_request is true - if ($this->compress_request) { - $content = gzencode($content); - - $req.= "Content-Encoding: gzip\r\n"; - } - - $req.= "Content-length: " . strlen($content) . "\r\n"; - $req.= "\r\n"; - $req.= $content; - - // Verify message size is below than 32KB - if (strlen($req) >= 32 * 1024) { - if ($this->debug()) { - $msg = "Message size is larger than 32KB"; - error_log("[Analytics][" . $this->type . "] " . $msg); - } - - return false; - } - - return $req; - } - - /** - * Parse our response from the server, check header and body. - * @param string $res - * @return array - * string $status HTTP code, e.g. "200" - * string $message JSON response from the api - */ - private function parseResponse($res) { - $contents = explode("\n", $res); - - // Response comes back as HTTP/1.1 200 OK - // Final line contains HTTP response. - $status = explode(" ", $contents[0], 3); - $result = $contents[count($contents) - 1]; - - return array( - "status" => isset($status[1]) ? $status[1] : null, - "message" => $result - ); - } -} diff --git a/lib/Segment/QueueConsumer.php b/lib/Segment/QueueConsumer.php deleted file mode 100644 index 682950f..0000000 --- a/lib/Segment/QueueConsumer.php +++ /dev/null @@ -1,156 +0,0 @@ -max_queue_size = $options["max_queue_size"]; - } - - if (isset($options["batch_size"])) { - $this->batch_size = $options["batch_size"]; - } - - if (isset($options["host"])) { - $this->host = $options["host"]; - } - - if (isset($options["compress_request"])) { - $this->compress_request = json_decode($options["compress_request"]); - } - - $this->queue = array(); - } - - public function __destruct() { - // Flush our queue on destruction - $this->flush(); - } - - /** - * Tracks a user action - * - * @param array $message - * @return boolean whether the track call succeeded - */ - public function track(array $message) { - return $this->enqueue($message); - } - - /** - * Tags traits about the user. - * - * @param array $message - * @return boolean whether the identify call succeeded - */ - public function identify(array $message) { - return $this->enqueue($message); - } - - /** - * Tags traits about the group. - * - * @param array $message - * @return boolean whether the group call succeeded - */ - public function group(array $message) { - return $this->enqueue($message); - } - - /** - * Tracks a page view. - * - * @param array $message - * @return boolean whether the page call succeeded - */ - public function page(array $message) { - return $this->enqueue($message); - } - - /** - * Tracks a screen view. - * - * @param array $message - * @return boolean whether the screen call succeeded - */ - public function screen(array $message) { - return $this->enqueue($message); - } - - /** - * Aliases from one user id to another - * - * @param array $message - * @return boolean whether the alias call succeeded - */ - public function alias(array $message) { - return $this->enqueue($message); - } - - /** - * Flushes our queue of messages by batching them to the server - */ - public function flush() { - $count = count($this->queue); - $success = true; - - while ($count > 0 && $success) { - $batch = array_splice($this->queue, 0, min($this->batch_size, $count)); - $success = $this->flushBatch($batch); - - $count = count($this->queue); - } - - return $success; - } - - /** - * Adds an item to our queue. - * @param mixed $item - * @return boolean whether call has succeeded - */ - protected function enqueue($item) { - $count = count($this->queue); - - if ($count > $this->max_queue_size) { - return false; - } - - $count = array_push($this->queue, $item); - - if ($count >= $this->batch_size) { - return $this->flush(); // return ->flush() result: true on success - } - - return true; - } - - /** - * Given a batch of messages the method returns - * a valid payload. - * - * @param {Array} $batch - * @return {Array} - */ - protected function payload($batch){ - return array( - "batch" => $batch, - "sentAt" => date("c"), - ); - } -} diff --git a/lib/Segment/Version.php b/lib/Segment/Version.php deleted file mode 100644 index 083fc80..0000000 --- a/lib/Segment/Version.php +++ /dev/null @@ -1,3 +0,0 @@ - The coding standard for analytics-php project. - + ./lib/ ./test/ - - - - - - - - - - - - + - - - - - - - + + */test/* - - + + + + + + + + + + + + + + + + + - + - - - 0 + + + - \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml index 36a1da3..3fcd1a6 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,36 +1,40 @@ - + + + + + ./lib + + + + + test - - - ./lib - - - + + + - \ No newline at end of file + diff --git a/send.php b/send.php index c8c0d4c..d3bb23c 100755 --- a/send.php +++ b/send.php @@ -1,10 +1,14 @@ true, - "error_handler" => function($code, $msg){ - print("$code: $msg\n"); - exit(1); - } -)); +Segment::init($args['secret'], [ + 'debug' => true, + 'error_handler' => static function ($code, $msg) { + print("$code: $msg\n"); + exit(1); + }, +]); /** * Payloads @@ -67,19 +77,34 @@ $total = 0; $successful = 0; foreach ($lines as $line) { - if (!trim($line)) continue; - $payload = json_decode($line, true); - $dt = new DateTime($payload["timestamp"]); - $ts = floatval($dt->getTimestamp() . "." . $dt->format("u")); - $payload["timestamp"] = $ts; - $type = $payload["type"]; - $ret = call_user_func_array(array("Segment", $type), array($payload)); - if ($ret) $successful++; - $total++; - if ($total % 100 === 0) Segment::flush(); + if (!trim($line)) { + continue; + } + $total++; + $payload = json_decode($line, true); + $dt = new DateTime($payload['timestamp']); + $ts = (float)($dt->getTimestamp() . '.' . $dt->format('u')); + $payload['timestamp'] = date('c', (int)$ts); + $type = $payload['type']; + $currentBatch[] = $payload; + // flush before batch gets too big + if (mb_strlen((json_encode(['batch' => $currentBatch, 'sentAt' => date('c')])), '8bit') >= 512000) { + $libCurlResponse = Segment::flush(); + if ($libCurlResponse) { + $successful += count($currentBatch) - 1; + //} else { + // todo: maybe write batch to analytics-error.log for more controlled errorhandling + } + $currentBatch = []; + } + $payload['timestamp'] = $ts; + call_user_func([Segment::class, $type], $payload); } -Segment::flush(); +$libCurlResponse = Segment::flush(); +if ($libCurlResponse) { + $successful += $total - $successful; +} unlink($file); /** @@ -93,14 +118,17 @@ * Parse arguments */ -function parse($argv){ - $ret = array(); +function parse($argv): array +{ + $ret = []; - for ($i = 0; $i < count($argv); ++$i) { - $arg = $argv[$i]; - if ('--' != substr($arg, 0, 2)) continue; - $ret[substr($arg, 2, strlen($arg))] = trim($argv[++$i]); - } + foreach ($argv as $i => $iValue) { + $arg = $iValue; + if (strpos($arg, '--') !== 0) { + continue; + } + $ret[substr($arg, 2, strlen($arg))] = trim($argv[++$i]); + } - return $ret; + return $ret; } diff --git a/test/AnalyticsTest.php b/test/AnalyticsTest.php index ede6259..039d917 100644 --- a/test/AnalyticsTest.php +++ b/test/AnalyticsTest.php @@ -1,249 +1,343 @@ true)); - } - - public function testTrack() - { - $this->assertTrue(Segment::track(array( - "userId" => "john", - "event" => "Module PHP Event", - ))); - } - - public function testGroup() - { - $this->assertTrue(Segment::group(array( - "groupId" => "group-id", - "userId" => "user-id", - "traits" => array( - "plan" => "startup", - ), - ))); - } - - public function testGroupAnonymous() - { - $this->assertTrue(Segment::group(array( - "groupId" => "group-id", - "anonymousId" => "anonymous-id", - "traits" => array( - "plan" => "startup", - ), - ))); - } - - /** - * @expectedException \Exception - * @expectedExceptionMessage Segment::group() requires userId or anonymousId - */ - public function testGroupNoUser() - { - Segment::group(array( - "groupId" => "group-id", - "traits" => array( - "plan" => "startup", - ), - )); - } - - public function testMicrotime() - { - $this->assertTrue(Segment::page(array( - "anonymousId" => "anonymous-id", - "name" => "analytics-php-microtime", - "category" => "docs", - "timestamp" => microtime(true), - "properties" => array( - "path" => "/docs/libraries/php/", - "url" => "https://segment.io/docs/libraries/php/", - ), - ))); - } - - public function testPage() - { - $this->assertTrue(Segment::page(array( - "anonymousId" => "anonymous-id", - "name" => "analytics-php", - "category" => "docs", - "properties" => array( - "path" => "/docs/libraries/php/", - "url" => "https://segment.io/docs/libraries/php/", - ), - ))); - } - - public function testBasicPage() - { - $this->assertTrue(Segment::page(array( - "anonymousId" => "anonymous-id", - ))); - } - - public function testScreen() - { - $this->assertTrue(Segment::screen(array( - "anonymousId" => "anonymous-id", - "name" => "2048", - "category" => "game built with php :)", - "properties" => array( - "points" => 300 - ), - ))); - } - - public function testBasicScreen() - { - $this->assertTrue(Segment::screen(array( - "anonymousId" => "anonymous-id" - ))); - } - - public function testIdentify() - { - $this->assertTrue(Segment::identify(array( - "userId" => "doe", - "traits" => array( - "loves_php" => false, - "birthday" => time(), - ), - ))); - } - - public function testEmptyTraits() - { - $this->assertTrue(Segment::identify(array( - "userId" => "empty-traits", - ))); - - $this->assertTrue(Segment::group(array( - "userId" => "empty-traits", - "groupId" => "empty-traits", - ))); - } - - public function testEmptyArrayTraits() - { - $this->assertTrue(Segment::identify(array( - "userId" => "empty-traits", - "traits" => array(), - ))); - - $this->assertTrue(Segment::group(array( - "userId" => "empty-traits", - "groupId" => "empty-traits", - "traits" => array(), - ))); - } - - public function testEmptyProperties() - { - $this->assertTrue(Segment::track(array( - "userId" => "user-id", - "event" => "empty-properties", - ))); - - $this->assertTrue(Segment::page(array( - "category" => "empty-properties", - "name" => "empty-properties", - "userId" => "user-id", - ))); - } - - public function testEmptyArrayProperties() - { - $this->assertTrue(Segment::track(array( - "userId" => "user-id", - "event" => "empty-properties", - "properties" => array(), - ))); - - $this->assertTrue(Segment::page(array( - "category" => "empty-properties", - "name" => "empty-properties", - "userId" => "user-id", - "properties" => array(), - ))); - } - - public function testAlias() - { - $this->assertTrue(Segment::alias(array( - "previousId" => "previous-id", - "userId" => "user-id", - ))); - } - - public function testContextEmpty() - { - $this->assertTrue(Segment::track(array( - "userId" => "user-id", - "event" => "Context Test", - "context" => array(), - ))); - } - - public function testContextCustom() - { - $this->assertTrue(Segment::track(array( - "userId" => "user-id", - "event" => "Context Test", - "context" => array( - "active" => false, - ), - ))); - } - - public function testTimestamps() - { - $this->assertTrue(Segment::track(array( - "userId" => "user-id", - "event" => "integer-timestamp", - "timestamp" => (int) mktime(0, 0, 0, date('n'), 1, date('Y')), - ))); - - $this->assertTrue(Segment::track(array( - "userId" => "user-id", - "event" => "string-integer-timestamp", - "timestamp" => (string) mktime(0, 0, 0, date('n'), 1, date('Y')), - ))); - - $this->assertTrue(Segment::track(array( - "userId" => "user-id", - "event" => "iso8630-timestamp", - "timestamp" => date(DATE_ATOM, mktime(0, 0, 0, date('n'), 1, date('Y'))), - ))); - - $this->assertTrue(Segment::track(array( - "userId" => "user-id", - "event" => "iso8601-timestamp", - "timestamp" => date(DATE_ATOM, mktime(0, 0, 0, date('n'), 1, date('Y'))), - ))); - - $this->assertTrue(Segment::track(array( - "userId" => "user-id", - "event" => "strtotime-timestamp", - "timestamp" => strtotime('1 week ago'), - ))); - - $this->assertTrue(Segment::track(array( - "userId" => "user-id", - "event" => "microtime-timestamp", - "timestamp" => microtime(true), - ))); - - $this->assertTrue(Segment::track(array( - "userId" => "user-id", - "event" => "invalid-float-timestamp", - "timestamp" => ((string) mktime(0, 0, 0, date('n'), 1, date('Y'))) . '.', - ))); - } + public function setUp(): void + { + date_default_timezone_set('UTC'); + Segment::init('oq0vdlg7yi', ['debug' => true]); + } + + public function testTrack(): void + { + self::assertTrue( + Segment::track( + [ + 'userId' => 'john', + 'event' => 'Module PHP Event', + ] + ) + ); + } + + public function testGroup(): void + { + self::assertTrue( + Segment::group( + [ + 'groupId' => 'group-id', + 'userId' => 'user-id', + 'traits' => [ + 'plan' => 'startup', + ], + ] + ) + ); + } + + public function testGroupAnonymous(): void + { + self::assertTrue( + Segment::group( + [ + 'groupId' => 'group-id', + 'anonymousId' => 'anonymous-id', + 'traits' => [ + 'plan' => 'startup', + ], + ] + ) + ); + } + + public function testGroupNoUser(): void + { + $this->expectExceptionMessage('Segment::group() requires userId or anonymousId'); + $this->expectException(SegmentException::class); + Segment::group( + [ + 'groupId' => 'group-id', + 'traits' => [ + 'plan' => 'startup', + ], + ] + ); + } + + public function testMicrotime(): void + { + self::assertTrue( + Segment::page( + [ + 'anonymousId' => 'anonymous-id', + 'name' => 'analytics-php-microtime', + 'category' => 'docs', + 'timestamp' => microtime(true), + 'properties' => [ + 'path' => '/docs/libraries/php/', + 'url' => 'https://segment.io/docs/libraries/php/', + ], + ] + ) + ); + } + + public function testPage(): void + { + self::assertTrue( + Segment::page( + [ + 'anonymousId' => 'anonymous-id', + 'name' => 'analytics-php', + 'category' => 'docs', + 'properties' => [ + 'path' => '/docs/libraries/php/', + 'url' => 'https://segment.io/docs/libraries/php/', + ], + ] + ) + ); + } + + public function testBasicPage(): void + { + self::assertTrue(Segment::page(['anonymousId' => 'anonymous-id'])); + } + + public function testScreen(): void + { + self::assertTrue( + Segment::screen( + [ + 'anonymousId' => 'anonymous-id', + 'name' => '2048', + 'category' => 'game built with php :)', + 'properties' => [ + 'points' => 300, + ], + ] + ) + ); + } + + public function testBasicScreen(): void + { + self::assertTrue(Segment::screen(['anonymousId' => 'anonymous-id'])); + } + + public function testIdentify(): void + { + self::assertTrue( + Segment::identify( + [ + 'userId' => 'doe', + 'traits' => [ + 'loves_php' => false, + 'birthday' => time(), + ], + ] + ) + ); + } + + public function testEmptyTraits(): void + { + self::assertTrue(Segment::identify(['userId' => 'empty-traits'])); + + self::assertTrue( + Segment::group( + [ + 'userId' => 'empty-traits', + 'groupId' => 'empty-traits', + ] + ) + ); + } + + public function testEmptyArrayTraits(): void + { + self::assertTrue( + Segment::identify( + [ + 'userId' => 'empty-traits', + 'traits' => [], + ] + ) + ); + + self::assertTrue( + Segment::group( + [ + 'userId' => 'empty-traits', + 'groupId' => 'empty-traits', + 'traits' => [], + ] + ) + ); + } + + public function testEmptyProperties(): void + { + self::assertTrue( + Segment::track( + [ + 'userId' => 'user-id', + 'event' => 'empty-properties', + ] + ) + ); + + self::assertTrue( + Segment::page( + [ + 'category' => 'empty-properties', + 'name' => 'empty-properties', + 'userId' => 'user-id', + ] + ) + ); + } + + public function testEmptyArrayProperties(): void + { + self::assertTrue( + Segment::track( + [ + 'userId' => 'user-id', + 'event' => 'empty-properties', + 'properties' => [], + ] + ) + ); + + self::assertTrue( + Segment::page( + [ + 'category' => 'empty-properties', + 'name' => 'empty-properties', + 'userId' => 'user-id', + 'properties' => [], + ] + ) + ); + } + + public function testAlias(): void + { + self::assertTrue( + Segment::alias( + [ + 'previousId' => 'previous-id', + 'userId' => 'user-id', + ] + ) + ); + } + + public function testContextEmpty(): void + { + self::assertTrue( + Segment::track( + [ + 'userId' => 'user-id', + 'event' => 'Context Test', + 'context' => [], + ] + ) + ); + } + + public function testContextCustom(): void + { + self::assertTrue( + Segment::track( + [ + 'userId' => 'user-id', + 'event' => 'Context Test', + 'context' => ['active' => false], + ] + ) + ); + } + + public function testTimestamps(): void + { + self::assertTrue( + Segment::track( + [ + 'userId' => 'user-id', + 'event' => 'integer-timestamp', + 'timestamp' => (int)mktime(0, 0, 0, (int)date('n'), 1, (int)date('Y')), + ] + ) + ); + + self::assertTrue( + Segment::track( + [ + 'userId' => 'user-id', + 'event' => 'string-integer-timestamp', + 'timestamp' => (string)mktime(0, 0, 0, (int)date('n'), 1, (int)date('Y')), + ] + ) + ); + + self::assertTrue( + Segment::track( + [ + 'userId' => 'user-id', + 'event' => 'iso8630-timestamp', + 'timestamp' => date(DATE_ATOM, mktime(0, 0, 0, (int)date('n'), 1, (int)date('Y'))), + ] + ) + ); + + self::assertTrue( + Segment::track( + [ + 'userId' => 'user-id', + 'event' => 'iso8601-timestamp', + 'timestamp' => date(DATE_ATOM, mktime(0, 0, 0, (int)date('n'), 1, (int)date('Y'))), + ] + ) + ); + + self::assertTrue( + Segment::track( + [ + 'userId' => 'user-id', + 'event' => 'strtotime-timestamp', + 'timestamp' => strtotime('1 week ago'), + ] + ) + ); + + self::assertTrue( + Segment::track( + [ + 'userId' => 'user-id', + 'event' => 'microtime-timestamp', + 'timestamp' => microtime(true), + ] + ) + ); + + self::assertTrue( + Segment::track( + [ + 'userId' => 'user-id', + 'event' => 'invalid-float-timestamp', + 'timestamp' => ((string)mktime(0, 0, 0, (int)date('n'), 1, (int)date('Y'))) . '.', + ] + ) + ); + } } diff --git a/test/ClientTest.php b/test/ClientTest.php new file mode 100644 index 0000000..23af9c9 --- /dev/null +++ b/test/ClientTest.php @@ -0,0 +1,36 @@ +getConsumer()); + } + + /** @test */ + public function can_provide_the_consumer_configuration_as_string(): void + { + $client = new Client('foobar', ['consumer' => 'fork_curl']); + self::assertInstanceOf(ForkCurl::class, $client->getConsumer()); + } + + /** @test */ + public function can_provide_a_class_namespace_as_consumer_configuration(): void + { + $client = new Client('foobar', [ + 'consumer' => ForkCurl::class, + ]); + self::assertInstanceOf(ForkCurl::class, $client->getConsumer()); + } +} diff --git a/test/ConsumerFileTest.php b/test/ConsumerFileTest.php index 510ffaf..1c8cba0 100644 --- a/test/ConsumerFileTest.php +++ b/test/ConsumerFileTest.php @@ -1,141 +1,174 @@ filename())) { - unlink($this->filename()); + private Client $client; + private string $filename = '/tmp/analytics.log'; + + public function setUp(): void + { + date_default_timezone_set('UTC'); + $this->clearLog(); + + $this->client = new Client( + 'oq0vdlg7yi', + [ + 'consumer' => 'file', + 'filename' => $this->filename, + ] + ); + } + + private function clearLog(): void + { + if (file_exists($this->filename)) { + unlink($this->filename); + } + } + + public function filename(): string + { + return $this->filename; + } + + public function tearDown(): void + { + $this->clearLog(); + } + + public function testTrack(): void + { + self::assertTrue($this->client->track([ + 'userId' => 'some-user', + 'event' => 'File PHP Event - Microtime', + 'timestamp' => microtime(true), + ])); + $this->checkWritten('track'); + } + + public function checkWritten($type): void + { + exec('wc -l ' . $this->filename, $output); + $out = trim($output[0]); + self::assertSame($out, '1 ' . $this->filename); + $str = file_get_contents($this->filename); + $json = json_decode(trim($str), false); + self::assertSame($type, $json->type); + unlink($this->filename); + } + + public function testIdentify(): void + { + self::assertTrue($this->client->identify([ + 'userId' => 'Calvin', + 'traits' => [ + 'loves_php' => false, + 'type' => 'analytics.log', + 'birthday' => time(), + ], + ])); + $this->checkWritten('identify'); } - $this->client = new Segment_Client( - "oq0vdlg7yi", - array( - "consumer" => "file", - "filename" => $this->filename, - ) - ); - } - - public function tearDown() - { - if (file_exists($this->filename)) { - unlink($this->filename); + public function testGroup(): void + { + self::assertTrue($this->client->group([ + 'userId' => 'user-id', + 'groupId' => 'group-id', + 'traits' => [ + 'type' => 'consumer analytics.log test', + ], + ])); } - } - - public function testTrack() - { - $this->assertTrue($this->client->track(array( - "userId" => "some-user", - "event" => "File PHP Event - Microtime", - "timestamp" => microtime(true), - ))); - $this->checkWritten("track"); - } - - public function testIdentify() - { - $this->assertTrue($this->client->identify(array( - "userId" => "Calvin", - "traits" => array( - "loves_php" => false, - "type" => "analytics.log", - "birthday" => time(), - ), - ))); - $this->checkWritten("identify"); - } - - public function testGroup() - { - $this->assertTrue($this->client->group(array( - "userId" => "user-id", - "groupId" => "group-id", - "traits" => array( - "type" => "consumer analytics.log test", - ), - ))); - } - - public function testPage() - { - $this->assertTrue($this->client->page(array( - "userId" => "user-id", - "name" => "analytics-php", - "category" => "analytics.log", - "properties" => array( - "url" => "https://a.url/", - ), - ))); - } - - public function testScreen() - { - $this->assertTrue($this->client->screen(array( - "userId" => "userId", - "name" => "grand theft auto", - "category" => "analytics.log", - "properties" => array(), - ))); - } - - public function testAlias() - { - $this->assertTrue($this->client->alias(array( - "previousId" => "previous-id", - "userId" => "user-id", - ))); - $this->checkWritten("alias"); - } - - public function testSend() - { - for ($i = 0; $i < 200; ++$i) { - $this->client->track(array( - "userId" => "userId", - "event" => "event", - )); + + public function testPage(): void + { + self::assertTrue($this->client->page([ + 'userId' => 'user-id', + 'name' => 'analytics-php', + 'category' => 'analytics.log', + 'properties' => ['url' => 'https://a.url/'], + ])); + } + + public function testScreen(): void + { + self::assertTrue($this->client->screen([ + 'userId' => 'userId', + 'name' => 'grand theft auto', + 'category' => 'analytics.log', + 'properties' => [], + ])); + } + + public function testAlias(): void + { + self::assertTrue($this->client->alias([ + 'previousId' => 'previous-id', + 'userId' => 'user-id', + ])); + $this->checkWritten('alias'); + } + + public function testSend(): void + { + for ($i = 0; $i < 200; ++$i) { + $this->client->track([ + 'userId' => 'userId', + 'event' => 'event', + ]); + } + exec('php --define date.timezone=UTC send.php --secret oq0vdlg7yi --file /tmp/analytics.log', $output); + self::assertSame('sent 200 from 200 requests successfully', trim($output[0])); + self::assertFileDoesNotExist($this->filename); + } + + public function testProductionProblems(): void + { + // Open to a place where we should not have write access. + $client = new Client( + 'oq0vdlg7yi', + [ + 'consumer' => 'file', + 'filename' => '/dev/xxxxxxx', + ] + ); + + $tracked = $client->track(['userId' => 'some-user', 'event' => 'my event']); + self::assertFalse($tracked); + } + + public function testFileSecurityCustom(): void + { + $client = new Client( + 'testsecret', + [ + 'consumer' => 'file', + 'filename' => $this->filename, + 'filepermissions' => 0600, + ] + ); + $client->track(['userId' => 'some_user', 'event' => 'File PHP Event']); + self::assertEquals(0600, (fileperms($this->filename) & 0777)); + } + + public function testFileSecurityDefaults(): void + { + $client = new Client( + 'testsecret', + [ + 'consumer' => 'file', + 'filename' => $this->filename, + ] + ); + $client->track(['userId' => 'some_user', 'event' => 'File PHP Event']); + self::assertEquals(0644, (fileperms($this->filename) & 0777)); } - exec("php --define date.timezone=UTC send.php --secret oq0vdlg7yi --file /tmp/analytics.log", $output); - $this->assertSame("sent 200 from 200 requests successfully", trim($output[0])); - $this->assertFileNotExists($this->filename()); - } - - public function testProductionProblems() - { - // Open to a place where we should not have write access. - $client = new Segment_Client( - "oq0vdlg7yi", - array( - "consumer" => "file", - "filename" => "/dev/xxxxxxx", - ) - ); - - $tracked = $client->track(array("userId" => "some-user", "event" => "my event")); - $this->assertFalse($tracked); - } - - public function checkWritten($type) - { - exec("wc -l " . $this->filename, $output); - $out = trim($output[0]); - $this->assertSame($out, "1 " . $this->filename); - $str = file_get_contents($this->filename); - $json = json_decode(trim($str)); - $this->assertSame($type, $json->type); - unlink($this->filename); - } - - public function filename() - { - return '/tmp/analytics.log'; - } } diff --git a/test/ConsumerForkCurlTest.php b/test/ConsumerForkCurlTest.php index d072b77..9d5f203 100644 --- a/test/ConsumerForkCurlTest.php +++ b/test/ConsumerForkCurlTest.php @@ -1,99 +1,103 @@ client = new Segment_Client( - "OnMMoZ6YVozrgSBeZ9FpkC0ixH0ycYZn", - array( - "consumer" => "fork_curl", - "debug" => true, - ) - ); - } + public function setUp(): void + { + date_default_timezone_set('UTC'); + $this->client = new Client( + 'OnMMoZ6YVozrgSBeZ9FpkC0ixH0ycYZn', + [ + 'consumer' => 'fork_curl', + 'debug' => true, + ] + ); + } - public function testTrack() - { - $this->assertTrue($this->client->track(array( - "userId" => "some-user", - "event" => "PHP Fork Curl'd\" Event", - ))); - } + public function testTrack(): void + { + self::assertTrue($this->client->track([ + 'userId' => 'some-user', + 'event' => "PHP Fork Curl'd\" Event", + ])); + } - public function testIdentify() - { - $this->assertTrue($this->client->identify(array( - "userId" => "user-id", - "traits" => array( - "loves_php" => false, - "type" => "consumer fork-curl test", - "birthday" => time(), - ), - ))); - } + public function testIdentify(): void + { + self::assertTrue($this->client->identify([ + 'userId' => 'user-id', + 'traits' => [ + 'loves_php' => false, + 'type' => 'consumer fork-curl test', + 'birthday' => time(), + ], + ])); + } - public function testGroup() - { - $this->assertTrue($this->client->group(array( - "userId" => "user-id", - "groupId" => "group-id", - "traits" => array( - "type" => "consumer fork-curl test", - ), - ))); - } + public function testGroup(): void + { + self::assertTrue($this->client->group([ + 'userId' => 'user-id', + 'groupId' => 'group-id', + 'traits' => [ + 'type' => 'consumer fork-curl test', + ], + ])); + } - public function testPage() - { - $this->assertTrue($this->client->page(array( - "userId" => "userId", - "name" => "analytics-php", - "category" => "fork-curl", - "properties" => array( - "url" => "https://a.url/", - ), - ))); - } + public function testPage(): void + { + self::assertTrue($this->client->page([ + 'userId' => 'userId', + 'name' => 'analytics-php', + 'category' => 'fork-curl', + 'properties' => ['url' => 'https://a.url/'], + ])); + } - public function testScreen() - { - $this->assertTrue($this->client->page(array( - "anonymousId" => "anonymous-id", - "name" => "grand theft auto", - "category" => "fork-curl", - "properties" => array(), - ))); - } + public function testScreen(): void + { + self::assertTrue($this->client->page([ + 'anonymousId' => 'anonymous-id', + 'name' => 'grand theft auto', + 'category' => 'fork-curl', + 'properties' => [], + ])); + } - public function testAlias() - { - $this->assertTrue($this->client->alias(array( - "previousId" => "previous-id", - "userId" => "user-id", - ))); - } + public function testAlias(): void + { + self::assertTrue($this->client->alias([ + 'previousId' => 'previous-id', + 'userId' => 'user-id', + ])); + } - public function testRequestCompression() { - $options = array( - "compress_request" => true, - "consumer" => "fork_curl", - "debug" => true, - ); + public function testRequestCompression(): void + { + $options = [ + 'compress_request' => true, + 'consumer' => 'fork_curl', + 'debug' => true, + ]; - // Create client and send Track message - $client = new Segment_Client("OnMMoZ6YVozrgSBeZ9FpkC0ixH0ycYZn", $options); - $result = $client->track(array( - "userId" => "some-user", - "event" => "PHP Fork Curl'd\" Event with compression", - )); - $client->__destruct(); + // Create client and send Track message + $client = new Client('OnMMoZ6YVozrgSBeZ9FpkC0ixH0ycYZn', $options); + $result = $client->track([ + 'userId' => 'some-user', + 'event' => "PHP Fork Curl'd\" Event with compression", + ]); + $client->__destruct(); - $this->assertTrue($result); - } + self::assertTrue($result); + } } diff --git a/test/ConsumerLibCurlTest.php b/test/ConsumerLibCurlTest.php index a54a42f..e2adc66 100644 --- a/test/ConsumerLibCurlTest.php +++ b/test/ConsumerLibCurlTest.php @@ -1,125 +1,126 @@ client = new Segment_Client( - "oq0vdlg7yi", - array( - "consumer" => "lib_curl", - "debug" => true, - ) - ); - } - - public function testTrack() - { - $this->assertTrue($this->client->track(array( - "userId" => "lib-curl-track", - "event" => "PHP Lib Curl'd\" Event", - ))); - } - - public function testIdentify() - { - $this->assertTrue($this->client->identify(array( - "userId" => "lib-curl-identify", - "traits" => array( - "loves_php" => false, - "type" => "consumer lib-curl test", - "birthday" => time(), - ), - ))); - } - - public function testGroup() - { - $this->assertTrue($this->client->group(array( - "userId" => "lib-curl-group", - "groupId" => "group-id", - "traits" => array( - "type" => "consumer lib-curl test", - ), - ))); - } - - public function testPage() - { - $this->assertTrue($this->client->page(array( - "userId" => "lib-curl-page", - "name" => "analytics-php", - "category" => "fork-curl", - "properties" => array( - "url" => "https://a.url/", - ), - ))); - } - - public function testScreen() - { - $this->assertTrue($this->client->page(array( - "anonymousId" => "lib-curl-screen", - "name" => "grand theft auto", - "category" => "fork-curl", - "properties" => array(), - ))); - } - - public function testAlias() - { - $this->assertTrue($this->client->alias(array( - "previousId" => "lib-curl-alias", - "userId" => "user-id", - ))); - } - - public function testRequestCompression() { - $options = array( - "compress_request" => true, - "consumer" => "lib_curl", - "error_handler" => function ($errno, $errmsg) { - throw new \RuntimeException($errmsg, $errno); - }, - ); - - $client = new Segment_Client("x", $options); - - # Should error out with debug on. - $client->track(array("user_id" => "some-user", "event" => "Socket PHP Event")); - $client->__destruct(); - } - - public function testLargeMessageSizeError() - { - $options = array( - "debug" => true, - "consumer" => "lib_curl", - ); - - $client = new Segment_Client("testlargesize", $options); - - $big_property = ""; - - for ($i = 0; $i < 32 * 1024; ++$i) { - $big_property .= "a"; + private Client $client; + + public function setUp(): void + { + date_default_timezone_set('UTC'); + $this->client = new Client( + 'oq0vdlg7yi', + [ + 'consumer' => 'lib_curl', + 'debug' => true, + ] + ); + } + + public function testTrack(): void + { + self::assertTrue($this->client->track([ + 'userId' => 'lib-curl-track', + 'event' => "PHP Lib Curl'd\" Event", + ])); + } + + public function testIdentify(): void + { + self::assertTrue($this->client->identify([ + 'userId' => 'lib-curl-identify', + 'traits' => [ + 'loves_php' => false, + 'type' => 'consumer lib-curl test', + 'birthday' => time(), + ], + ])); } - $this->assertFalse( - $client->track( - array( - "userId" => "some-user", - "event" => "Super Large PHP Event", - "properties" => array("big_property" => $big_property), - ) - ) && $client->flush() - ); - - $client->__destruct(); - } + public function testGroup(): void + { + self::assertTrue($this->client->group([ + 'userId' => 'lib-curl-group', + 'groupId' => 'group-id', + 'traits' => [ + 'type' => 'consumer lib-curl test', + ], + ])); + } + + public function testPage(): void + { + self::assertTrue($this->client->page([ + 'userId' => 'lib-curl-page', + 'name' => 'analytics-php', + 'category' => 'fork-curl', + 'properties' => ['url' => 'https://a.url/'], + ])); + } + + public function testScreen(): void + { + self::assertTrue($this->client->page([ + 'anonymousId' => 'lib-curl-screen', + 'name' => 'grand theft auto', + 'category' => 'fork-curl', + 'properties' => [], + ])); + } + + public function testAlias(): void + { + self::assertTrue($this->client->alias([ + 'previousId' => 'lib-curl-alias', + 'userId' => 'user-id', + ])); + } + + public function testRequestCompression(): void + { + $options = [ + 'compress_request' => true, + 'consumer' => 'lib_curl', + 'error_handler' => function ($errno, $errmsg) { + throw new RuntimeException($errmsg, $errno); + }, + ]; + + $client = new Client('oq0vdlg7yi', $options); + + # Should error out with debug on. + self::assertTrue($client->track(['user_id' => 'some-user', 'event' => 'Socket PHP Event'])); + $client->__destruct(); + } + + public function testLargeMessageSizeError(): void + { + $options = [ + 'debug' => true, + 'consumer' => 'lib_curl', + ]; + + $client = new Client('testlargesize', $options); + + $big_property = str_repeat('a', 32 * 1024); + + self::assertFalse( + $client->track( + [ + 'userId' => 'some-user', + 'event' => 'Super Large PHP Event', + 'properties' => ['big_property' => $big_property], + ] + ) && $client->flush() + ); + + $client->__destruct(); + } } diff --git a/test/ConsumerSocketTest.php b/test/ConsumerSocketTest.php index 8575bba..f4096c7 100644 --- a/test/ConsumerSocketTest.php +++ b/test/ConsumerSocketTest.php @@ -1,220 +1,256 @@ client = new Segment_Client( - "oq0vdlg7yi", - array("consumer" => "socket") - ); - } - - public function testTrack() - { - $this->assertTrue($this->client->track(array( - "userId" => "some-user", - "event" => "Socket PHP Event", - ))); - } - - public function testIdentify() - { - $this->assertTrue($this->client->identify(array( - "userId" => "Calvin", - "traits" => array( - "loves_php" => false, - "birthday" => time(), - ), - ))); - } - - public function testGroup() - { - $this->assertTrue($this->client->group(array( - "userId" => "user-id", - "groupId" => "group-id", - "traits" => array( - "type" => "consumer socket test", - ), - ))); - } - - public function testPage() - { - $this->assertTrue($this->client->page(array( - "userId" => "user-id", - "name" => "analytics-php", - "category" => "socket", - "properties" => array( - "url" => "https://a.url/", - ), - ))); - } - - public function testScreen() - { - $this->assertTrue($this->client->screen(array( - "anonymousId" => "anonymousId", - "name" => "grand theft auto", - "category" => "socket", - "properties" => array(), - ))); - } - - public function testAlias() - { - $this->assertTrue($this->client->alias(array( - "previousId" => "some-socket", - "userId" => "new-socket", - ))); - } - - public function testShortTimeout() - { - $client = new Segment_Client( - "oq0vdlg7yi", - array( - "timeout" => 0.01, - "consumer" => "socket", - ) - ); - - $this->assertTrue($client->track(array( - "userId" => "some-user", - "event" => "Socket PHP Event", - ))); - - $this->assertTrue($client->identify(array( - "userId" => "some-user", - "traits" => array(), - ))); - - $client->__destruct(); - } - - public function testProductionProblems() - { - $client = new Segment_Client("x", - array( - "consumer" => "socket", - "error_handler" => function () { - throw new Exception("Was called"); - }, - ) - ); - - // Shouldn't error out without debug on. - $client->track(array("user_id" => "some-user", "event" => "Production Problems")); - $client->__destruct(); - } - - public function testDebugProblems() - { - $options = array( - "debug" => true, - "consumer" => "socket", - "error_handler" => function ($errno, $errmsg) { - if (400 != $errno) { - throw new Exception("Response is not 400"); - } - }, - ); - - $client = new Segment_Client("x", $options); - - // Should error out with debug on. - $client->track(array("user_id" => "some-user", "event" => "Socket PHP Event")); - $client->__destruct(); - } - - public function testLargeMessage() - { - $options = array( - "debug" => true, - "consumer" => "socket", - ); - - $client = new Segment_Client("testsecret", $options); - - $big_property = ""; - - for ($i = 0; $i < 10000; ++$i) { - $big_property .= "a"; + private Client $client; + + public function setUp(): void + { + date_default_timezone_set('UTC'); + $this->client = new Client( + 'oq0vdlg7yi', + ['consumer' => 'socket'] + ); + } + + public function testTrack(): void + { + self::assertTrue( + $this->client->track( + [ + 'userId' => 'some-user', + 'event' => 'Socket PHP Event', + ] + ) + ); + } + + public function testIdentify(): void + { + self::assertTrue( + $this->client->identify( + [ + 'userId' => 'Calvin', + 'traits' => [ + 'loves_php' => false, + 'birthday' => time(), + ], + ] + ) + ); + } + + public function testGroup(): void + { + self::assertTrue( + $this->client->group( + [ + 'userId' => 'user-id', + 'groupId' => 'group-id', + 'traits' => [ + 'type' => 'consumer socket test', + ], + ] + ) + ); + } + + public function testPage(): void + { + self::assertTrue( + $this->client->page( + [ + 'userId' => 'user-id', + 'name' => 'analytics-php', + 'category' => 'socket', + 'properties' => ['url' => 'https://a.url/'], + ] + ) + ); + } + + public function testScreen(): void + { + self::assertTrue( + $this->client->screen( + [ + 'anonymousId' => 'anonymousId', + 'name' => 'grand theft auto', + 'category' => 'socket', + 'properties' => [], + ] + ) + ); + } + + public function testAlias(): void + { + self::assertTrue( + $this->client->alias( + [ + 'previousId' => 'some-socket', + 'userId' => 'new-socket', + ] + ) + ); } - $this->assertTrue($client->track(array( - "userId" => "some-user", - "event" => "Super Large PHP Event", - "properties" => array("big_property" => $big_property), - ))); + public function testShortTimeout(): void + { + $client = new Client( + 'oq0vdlg7yi', + [ + 'timeout' => 0.01, + 'consumer' => 'socket', + ] + ); + + self::assertTrue( + $client->track( + [ + 'userId' => 'some-user', + 'event' => 'Socket PHP Event', + ] + ) + ); + + self::assertTrue( + $client->identify( + [ + 'userId' => 'some-user', + 'traits' => [], + ] + ) + ); + + $client->__destruct(); + } + + public function testProductionProblems(): void + { + $client = new Client( + 'x', + [ + 'consumer' => 'socket', + 'error_handler' => function () { + throw new Exception('Was called'); + }, + ] + ); + + // Shouldn't error out without debug on. + self::assertTrue($client->track(['user_id' => 'some-user', 'event' => 'Production Problems'])); + $client->__destruct(); + } + + public function testDebugProblems(): void + { + $options = [ + 'debug' => true, + 'consumer' => 'socket', + 'error_handler' => function ($errno, $errmsg) { + if ($errno !== 400) { + throw new Exception('Response is not 400'); + } + }, + ]; + + $client = new Client('oq0vdlg7yi', $options); + + // Should error out with debug on. + self::assertTrue($client->track(['user_id' => 'some-user', 'event' => 'Socket PHP Event'])); + $client->__destruct(); + } + + public function testLargeMessage(): void + { + $options = [ + 'debug' => true, + 'consumer' => 'socket', + ]; + + $client = new Client('testsecret', $options); - $client->__destruct(); - } + $big_property = str_repeat('a', 10000); - public function testLargeMessageSizeError() - { - $options = array( - "debug" => true, - "consumer" => "socket", - ); + self::assertTrue( + $client->track( + [ + 'userId' => 'some-user', + 'event' => 'Super Large PHP Event', + 'properties' => ['big_property' => $big_property], + ] + ) + ); - $client = new Segment_Client("testlargesize", $options); + $client->__destruct(); + } + + public function testLargeMessageSizeError(): void + { + $options = [ + 'debug' => true, + 'consumer' => 'socket', + ]; + + $client = new Client('testlargesize', $options); + + $big_property = str_repeat('a', 32 * 1024); - $big_property = ""; + self::assertFalse( + $client->track( + [ + 'userId' => 'some-user', + 'event' => 'Super Large PHP Event', + 'properties' => ['big_property' => $big_property], + ] + ) && $client->flush() + ); - for ($i = 0; $i < 32 * 1024; ++$i) { - $big_property .= "a"; + $client->__destruct(); } - $this->assertFalse( - $client->track( - array( - "userId" => "some-user", - "event" => "Super Large PHP Event", - "properties" => array("big_property" => $big_property), - ) - ) && $client->flush() - ); - - $client->__destruct(); - } - - /** - * @expectedException \RuntimeException - */ - public function testConnectionError() - { - $client = new Segment_Client("x", array( - "consumer" => "socket", - "host" => "api.segment.ioooooo", - "error_handler" => function ($errno, $errmsg) { - throw new \RuntimeException($errmsg, $errno); - }, - )); - - $client->track(array("user_id" => "some-user", "event" => "Event")); - $client->__destruct(); - } - - public function testRequestCompression() { - $options = array( - "compress_request" => true, - "consumer" => "socket", - "error_handler" => function ($errno, $errmsg) { - throw new \RuntimeException($errmsg, $errno); - }, - ); - - $client = new Segment_Client("x", $options); - - # Should error out with debug on. - $client->track(array("user_id" => "some-user", "event" => "Socket PHP Event")); - $client->__destruct(); - } + public function testConnectionError(): void + { + $this->expectException(RuntimeException::class); + $client = new Client( + 'x', + [ + 'consumer' => 'socket', + 'host' => 'api.segment.ioooooo', + 'error_handler' => function ($errno, $errmsg) { + throw new RuntimeException($errmsg, $errno); + }, + ] + ); + + $client->track(['user_id' => 'some-user', 'event' => 'Event']); + $client->__destruct(); + } + + public function testRequestCompression(): void + { + $options = [ + 'compress_request' => true, + 'consumer' => 'socket', + 'error_handler' => function ($errno, $errmsg) { + throw new RuntimeException($errmsg, $errno); + }, + ]; + + $client = new Client('x', $options); + + # Should error out with debug on. + self::assertTrue($client->track(['user_id' => 'some-user', 'event' => 'Socket PHP Event'])); + $client->__destruct(); + } } diff --git a/test/SegmentTest.php b/test/SegmentTest.php new file mode 100644 index 0000000..29794ab --- /dev/null +++ b/test/SegmentTest.php @@ -0,0 +1,78 @@ +expectException(SegmentException::class); + $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); + + Segment::alias([]); + } + public function testFlushThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void + { + $this->expectException(SegmentException::class); + $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); + + Segment::flush(); + } + public function testGroupThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void + { + $this->expectException(SegmentException::class); + $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); + + Segment::group([]); + } + public function testIdentifyThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void + { + $this->expectException(SegmentException::class); + $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); + + Segment::identify([]); + } + public function testPageThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void + { + $this->expectException(SegmentException::class); + $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); + + Segment::page([]); + } + public function testScreenThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void + { + $this->expectException(SegmentException::class); + $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); + + Segment::screen([]); + } + public function testTrackThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void + { + $this->expectException(SegmentException::class); + $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); + + Segment::track([]); + } + + private static function resetSegment(): void + { + $property = new \ReflectionProperty( + Segment::class, + 'client' + ); + + $property->setAccessible(true); + $property->setValue(null); + } +} diff --git a/test/file.log b/test/file.log deleted file mode 100755 index 11791af..0000000 --- a/test/file.log +++ /dev/null @@ -1,6 +0,0 @@ -{"userId":"some-user","event":"File PHP Event","context":{"library":{"name":"analytics-php","version":"1.0.0"}},"timestamp":"2014-05-13T16:19:17+00:00","messageId":"f8c9cda1-f21b-4d40-8198-085eaa99dedb","type":"track"} -{"userId":"Calvin","traits":{"loves_php":false,"type":"analytics.log","birthday":1399997957},"context":{"library":{"name":"analytics-php","version":"1.0.0"}},"timestamp":"2014-05-13T16:19:17+00:00","messageId":"fe37073d-40fa-41e1-b826-8659fe74a199","type":"identify"} -{"userId":"user-id","groupId":"group-id","traits":{"type":"consumer analytics.log test"},"context":{"library":{"name":"analytics-php","version":"1.0.0"}},"timestamp":"2014-05-13T16:19:17+00:00","messageId":"8e6bb92a-7347-424a-94d6-a4234617b070","type":"group"} -{"userId":"user-id","name":"analytics-php","category":"analytics.log","properties":{"url":"https:\/\/a.url\/"},"context":{"library":{"name":"analytics-php","version":"1.0.0"}},"timestamp":"2014-05-13T16:19:17+00:00","messageId":"b3db73cd-337b-4b50-8967-f653f9183e02","type":"page"} -{"userId":"userId","name":"grand theft auto","category":"analytics.log","properties":[],"context":{"library":{"name":"analytics-php","version":"1.0.0"}},"timestamp":"2014-05-13T16:19:17+00:00","messageId":"2527e1a0-e631-47b1-872e-f575f0722547","type":"page"} -{"previousId":"previous-id","userId":"user-id","context":{"library":{"name":"analytics-php","version":"1.0.0"}},"timestamp":"2014-05-13T16:19:17+00:00","messageId":"5f883f43-c15c-49c6-b4e6-59d5cb6d657b","type":"alias"}