├── .github ├── CODEOWNERS └── workflows │ ├── create_jira.yml │ └── tests.yml ├── .phplint.yml ├── lib ├── Version.php ├── SegmentException.php ├── Consumer │ ├── ForkCurl.php │ ├── Consumer.php │ ├── LibCurl.php │ ├── File.php │ ├── Socket.php │ └── QueueConsumer.php ├── Segment.php └── Client.php ├── .gitignore ├── RELEASING.md ├── .buildscript ├── bootstrap.sh └── e2e.sh ├── .codecov.yml ├── bootstrap.php ├── test ├── ClientTest.php ├── SegmentTest.php ├── ConsumerForkCurlTest.php ├── ConsumerLibCurlTest.php ├── ConsumerFileTest.php ├── ConsumerSocketTest.php └── AnalyticsTest.php ├── LICENSE.md ├── Makefile ├── phpunit.xml ├── composer.json ├── phpcs.xml ├── bin └── analytics ├── send.php ├── .php_cs ├── README.md └── HISTORY.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global users 2 | @lubird @bsneed @pooyaj -------------------------------------------------------------------------------- /.phplint.yml: -------------------------------------------------------------------------------- 1 | path: ./ 2 | jobs: 10 3 | extensions: 4 | - php 5 | exclude: 6 | - vendor -------------------------------------------------------------------------------- /lib/Version.php: -------------------------------------------------------------------------------- 1 | /dev/null; then 4 | echo "homebrew is not available. Install it from http://brew.sh" 5 | exit 1 6 | else 7 | echo "homebrew already installed" 8 | fi 9 | 10 | if ! which php >/dev/null; then 11 | echo "installing php." 12 | brew install php 13 | else 14 | echo "php already installed" 15 | fi 16 | 17 | echo "all dependencies installed." 18 | -------------------------------------------------------------------------------- /.buildscript/e2e.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | 5 | if [ "$RUN_E2E_TESTS" != "true" ]; then 6 | echo "Skipping end to end tests." 7 | else 8 | echo "Running end to end tests..." 9 | wget https://github.com/segmentio/library-e2e-tester/releases/download/0.4.1-pre1/tester_linux_amd64 -O tester 10 | chmod +x tester 11 | ./tester -path='./bin/analytics' 12 | echo "End to end tests completed!" 13 | fi 14 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | after_n_builds: 2 4 | 5 | coverage: 6 | round: nearest 7 | # Status will be green when coverage is between 70 and 100%. 8 | range: "70...100" 9 | status: 10 | project: 11 | default: 12 | threshold: 2% 13 | paths: 14 | - "src" 15 | patch: 16 | default: 17 | threshold: 0% 18 | paths: 19 | - "src" 20 | 21 | comment: false 22 | -------------------------------------------------------------------------------- /bootstrap.php: -------------------------------------------------------------------------------- 1 | getConsumer()); 19 | } 20 | 21 | /** @test */ 22 | public function can_provide_the_consumer_configuration_as_string(): void 23 | { 24 | $client = new Client('foobar', ['consumer' => 'fork_curl']); 25 | self::assertInstanceOf(ForkCurl::class, $client->getConsumer()); 26 | } 27 | 28 | /** @test */ 29 | public function can_provide_a_class_namespace_as_consumer_configuration(): void 30 | { 31 | $client = new Client('foobar', [ 32 | 'consumer' => ForkCurl::class, 33 | ]); 34 | self::assertInstanceOf(ForkCurl::class, $client->getConsumer()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014 Segment Inc. friends@segment.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | bootstrap: 2 | .buildscript/bootstrap.sh 3 | 4 | dependencies: vendor 5 | 6 | vendor: composer.phar 7 | @php ./composer.phar install 8 | 9 | composer.phar: 10 | @curl -sS https://getcomposer.org/installer | php 11 | 12 | test: lint 13 | @vendor/bin/phpunit --colors test/ 14 | @php ./composer.phar validate 15 | 16 | lint: dependencies 17 | @if php -r 'exit(version_compare(PHP_VERSION, "5.5", ">=") ? 0 : 1);'; \ 18 | then \ 19 | php ./composer.phar require overtrue/phplint --dev; \ 20 | php ./composer.phar require squizlabs/php_codesniffer --dev; \ 21 | php ./composer.phar require dealerdirect/phpcodesniffer-composer-installer --dev; \ 22 | 23 | ./vendor/bin/phplint; \ 24 | ./vendor/bin/phpcs; \ 25 | else \ 26 | printf "Please update PHP version to 5.5 or above for code formatting."; \ 27 | fi 28 | 29 | release: 30 | @printf "releasing ${VERSION}..." 31 | @printf ' ./lib/Version.php 32 | @git changelog -t ${VERSION} 33 | @git release ${VERSION} 34 | 35 | clean: 36 | rm -rf \ 37 | composer.phar \ 38 | vendor \ 39 | composer.lock 40 | 41 | .PHONY: bootstrap release clean 42 | -------------------------------------------------------------------------------- /.github/workflows/create_jira.yml: -------------------------------------------------------------------------------- 1 | name: Create Jira Ticket 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | create_jira: 10 | name: Create Jira Ticket 11 | runs-on: ubuntu-latest 12 | environment: IssueTracker 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@master 16 | - name: Login 17 | uses: atlassian/gajira-login@master 18 | env: 19 | JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} 20 | JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} 21 | JIRA_API_TOKEN: ${{ secrets.JIRA_TOKEN }} 22 | JIRA_EPIC_KEY: ${{ secrets.JIRA_EPIC_KEY }} 23 | JIRA_PROJECT: ${{ secrets.JIRA_PROJECT }} 24 | 25 | - name: Create 26 | id: create 27 | uses: atlassian/gajira-create@master 28 | with: 29 | project: ${{ secrets.JIRA_PROJECT }} 30 | issuetype: Bug 31 | summary: | 32 | [${{ github.event.repository.name }}] (${{ github.event.issue.number }}): ${{ github.event.issue.title }} 33 | description: | 34 | Github Link: ${{ github.event.issue.html_url }} 35 | ${{ github.event.issue.body }} 36 | fields: '{"parent": {"key": "${{ secrets.JIRA_EPIC_KEY }}"}}' 37 | 38 | - name: Log created issue 39 | run: echo "Issue ${{ steps.create.outputs.issue }} was created" -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 22 | 23 | 24 | ./lib 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | test 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "segmentio/analytics-php", 3 | "description": "Segment Analytics PHP Library", 4 | "keywords": [ 5 | "analytics", 6 | "segmentio", 7 | "segment", 8 | "analytics.js" 9 | ], 10 | "homepage": "https://segment.com/libraries/php", 11 | "license": "MIT", 12 | "authors": [ 13 | { 14 | "name": "Segment.io ", 15 | "homepage": "https://segment.com/" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.4 || ^8.0", 20 | "ext-json": "*" 21 | }, 22 | "require-dev": { 23 | "phpunit/phpunit": "~9.5", 24 | "overtrue/phplint": "^3.0", 25 | "squizlabs/php_codesniffer": "^3.6", 26 | "roave/security-advisories": "dev-latest", 27 | "slevomat/coding-standard": "^7.0", 28 | "php-parallel-lint/php-parallel-lint": "^1.3", 29 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7" 30 | }, 31 | "suggest": { 32 | "ext-curl": "For using the curl HTTP client", 33 | "ext-zlib": "For using compression" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Segment\\": "lib/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Segment\\Test\\": "test/" 43 | } 44 | }, 45 | "bin": [ 46 | "bin/analytics" 47 | ], 48 | "scripts": { 49 | "test": "./vendor/bin/phpunit --no-coverage", 50 | "check": "./vendor/bin/phpcs", 51 | "cf": "./vendor/bin/phpcbf", 52 | "coverage": "./vendor/bin/phpunit", 53 | "lint": [ 54 | "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . -e php,phps --exclude vendor --exclude .git --exclude build" 55 | ] 56 | }, 57 | "config": { 58 | "allow-plugins": { 59 | "segmentio/*": true, 60 | "dealerdirect/phpcodesniffer-composer-installer": true 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | The coding standard for analytics-php project. 4 | 5 | ./lib/ 6 | ./test/ 7 | 8 | 9 | 10 | 11 | */test/* 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /bin/analytics: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | $options['userId'], 40 | 'event' => $options['event'], 41 | 'properties' => parse_json($options['properties']) 42 | )); 43 | break; 44 | case 'identify': 45 | Segment::identify(array( 46 | 'userId' => $options['userId'], 47 | 'traits' => parse_json($options['traits']) 48 | )); 49 | break; 50 | case 'page': 51 | Segment::page(array( 52 | 'userId' => $options['userId'], 53 | 'name' => $options['name'], 54 | 'properties' => parse_json($options['properties']) 55 | )); 56 | break; 57 | case 'group': 58 | Segment::identify(array( 59 | 'userId' => $options['userId'], 60 | 'groupId' => $options['groupId'], 61 | 'traits' => parse_json($options['traits']) 62 | )); 63 | break; 64 | case 'alias': 65 | Segment::alias(array( 66 | 'userId' => $options['userId'], 67 | 'previousId' => $options['previousId'] 68 | )); 69 | break; 70 | default: 71 | error(usage()); 72 | } 73 | 74 | Segment::flush(); 75 | 76 | function usage(): string 77 | { 78 | return "\n Usage: analytics --type [options]\n\n"; 79 | } 80 | 81 | function error($message): void 82 | { 83 | print("$message\n\n"); 84 | exit(1); 85 | } 86 | 87 | function parse_json($input): ?array 88 | { 89 | if (empty($input)) { 90 | return null; 91 | } 92 | 93 | return json_decode($input, true); 94 | } 95 | -------------------------------------------------------------------------------- /test/SegmentTest.php: -------------------------------------------------------------------------------- 1 | expectException(SegmentException::class); 21 | $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); 22 | 23 | Segment::alias([]); 24 | } 25 | public function testFlushThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void 26 | { 27 | $this->expectException(SegmentException::class); 28 | $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); 29 | 30 | Segment::flush(); 31 | } 32 | public function testGroupThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void 33 | { 34 | $this->expectException(SegmentException::class); 35 | $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); 36 | 37 | Segment::group([]); 38 | } 39 | public function testIdentifyThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void 40 | { 41 | $this->expectException(SegmentException::class); 42 | $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); 43 | 44 | Segment::identify([]); 45 | } 46 | public function testPageThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void 47 | { 48 | $this->expectException(SegmentException::class); 49 | $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); 50 | 51 | Segment::page([]); 52 | } 53 | public function testScreenThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void 54 | { 55 | $this->expectException(SegmentException::class); 56 | $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); 57 | 58 | Segment::screen([]); 59 | } 60 | public function testTrackThrowsSegmentExceptionWhenClientHasNotBeenInitialized(): void 61 | { 62 | $this->expectException(SegmentException::class); 63 | $this->expectExceptionMessage('Segment::init() must be called before any other tracking method.'); 64 | 65 | Segment::track([]); 66 | } 67 | 68 | private static function resetSegment(): void 69 | { 70 | $property = new \ReflectionProperty( 71 | Segment::class, 72 | 'client' 73 | ); 74 | 75 | $property->setAccessible(true); 76 | $property->setValue(null); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /lib/Consumer/ForkCurl.php: -------------------------------------------------------------------------------- 1 | payload($messages); 20 | $payload = json_encode($body); 21 | 22 | // Escape for shell usage. 23 | $payload = escapeshellarg($payload); 24 | $secret = escapeshellarg($this->secret); 25 | 26 | if ($this->host) { 27 | $host = $this->host; 28 | } else { 29 | $host = 'api.segment.io'; 30 | } 31 | $path = '/v1/batch'; 32 | $url = $this->protocol . $host . $path; 33 | 34 | $cmd = "curl -u $secret: -X POST -H 'Content-Type: application/json'"; 35 | 36 | $tmpfname = ''; 37 | if ($this->compress_request) { 38 | // Compress request to file 39 | $tmpfname = tempnam('/tmp', 'forkcurl_'); 40 | $cmd2 = 'echo ' . $payload . ' | gzip > ' . $tmpfname; 41 | exec($cmd2, $output, $exit); 42 | 43 | if ($exit !== 0) { 44 | $output = implode(PHP_EOL, $output); 45 | $this->handleError($exit, $output); 46 | return false; 47 | } 48 | 49 | $cmd .= " -H 'Content-Encoding: gzip'"; 50 | 51 | $cmd .= " --data-binary '@" . $tmpfname . "'"; 52 | } else { 53 | $cmd .= ' -d ' . $payload; 54 | } 55 | 56 | $cmd .= " '" . $url . "'"; 57 | 58 | // Verify payload size is below 512KB 59 | if (strlen($payload) >= 500 * 1024) { 60 | $msg = 'Payload size is larger than 512KB'; 61 | error_log('[Analytics][' . $this->type . '] ' . $msg); 62 | 63 | return false; 64 | } 65 | 66 | // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. 67 | $library = $messages[0]['context']['library']; 68 | $libName = $library['name']; 69 | $libVersion = $library['version']; 70 | $cmd .= " -H 'User-Agent: $libName/$libVersion'"; 71 | 72 | if (!$this->debug()) { 73 | $cmd .= ' > /dev/null 2>&1 &'; 74 | } 75 | 76 | exec($cmd, $output, $exit); 77 | 78 | if ($exit !== 0) { 79 | $output = implode(PHP_EOL, $output); 80 | $this->handleError($exit, $output); 81 | } 82 | 83 | if ($tmpfname !== '') { 84 | unlink($tmpfname); 85 | } 86 | 87 | return $exit === 0; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /lib/Consumer/Consumer.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | protected array $options; 15 | protected string $secret; 16 | 17 | /** 18 | * Store our secret and options as part of this consumer 19 | * @param string $secret 20 | * @param array $options 21 | */ 22 | public function __construct(string $secret, array $options = []) 23 | { 24 | $this->secret = $secret; 25 | $this->options = $options; 26 | } 27 | 28 | /** 29 | * Tracks a user action 30 | * 31 | * @param array $message 32 | * @return bool whether the track call succeeded 33 | */ 34 | abstract public function track(array $message): bool; 35 | 36 | /** 37 | * Tags traits about the user. 38 | * 39 | * @param array $message 40 | * @return bool whether the identify call succeeded 41 | */ 42 | abstract public function identify(array $message): bool; 43 | 44 | /** 45 | * Tags traits about the group. 46 | * 47 | * @param array $message 48 | * @return bool whether the group call succeeded 49 | */ 50 | abstract public function group(array $message): bool; 51 | 52 | /** 53 | * Tracks a page view. 54 | * 55 | * @param array $message 56 | * @return bool whether the page call succeeded 57 | */ 58 | abstract public function page(array $message): bool; 59 | 60 | /** 61 | * Tracks a screen view. 62 | * 63 | * @param array $message 64 | * @return bool whether the group call succeeded 65 | */ 66 | abstract public function screen(array $message): bool; 67 | 68 | /** 69 | * Aliases from one user id to another 70 | * 71 | * @param array $message 72 | * @return bool whether the alias call succeeded 73 | */ 74 | abstract public function alias(array $message): bool; 75 | 76 | /** 77 | * Getter method for consumer type. 78 | * 79 | * @return string 80 | */ 81 | public function getConsumer(): string 82 | { 83 | return $this->type; 84 | } 85 | 86 | /** 87 | * On an error, try and call the error handler, if debugging output to 88 | * error_log as well. 89 | * @param int $code 90 | * @param string $msg 91 | */ 92 | protected function handleError(int $code, string $msg): void 93 | { 94 | if (isset($this->options['error_handler'])) { 95 | $handler = $this->options['error_handler']; 96 | $handler($code, $msg); 97 | } 98 | 99 | if ($this->debug()) { 100 | error_log('[Analytics][' . $this->type . '] ' . $msg); 101 | } 102 | } 103 | 104 | /** 105 | * Check whether debug mode is enabled 106 | * @return bool 107 | */ 108 | protected function debug(): bool 109 | { 110 | return $this->options['debug'] ?? false; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /test/ConsumerForkCurlTest.php: -------------------------------------------------------------------------------- 1 | client = new Client( 18 | 'OnMMoZ6YVozrgSBeZ9FpkC0ixH0ycYZn', 19 | [ 20 | 'consumer' => 'fork_curl', 21 | 'debug' => true, 22 | ] 23 | ); 24 | } 25 | 26 | public function testTrack(): void 27 | { 28 | self::assertTrue($this->client->track([ 29 | 'userId' => 'some-user', 30 | 'event' => "PHP Fork Curl'd\" Event", 31 | ])); 32 | } 33 | 34 | public function testIdentify(): void 35 | { 36 | self::assertTrue($this->client->identify([ 37 | 'userId' => 'user-id', 38 | 'traits' => [ 39 | 'loves_php' => false, 40 | 'type' => 'consumer fork-curl test', 41 | 'birthday' => time(), 42 | ], 43 | ])); 44 | } 45 | 46 | public function testGroup(): void 47 | { 48 | self::assertTrue($this->client->group([ 49 | 'userId' => 'user-id', 50 | 'groupId' => 'group-id', 51 | 'traits' => [ 52 | 'type' => 'consumer fork-curl test', 53 | ], 54 | ])); 55 | } 56 | 57 | public function testPage(): void 58 | { 59 | self::assertTrue($this->client->page([ 60 | 'userId' => 'userId', 61 | 'name' => 'analytics-php', 62 | 'category' => 'fork-curl', 63 | 'properties' => ['url' => 'https://a.url/'], 64 | ])); 65 | } 66 | 67 | public function testScreen(): void 68 | { 69 | self::assertTrue($this->client->page([ 70 | 'anonymousId' => 'anonymous-id', 71 | 'name' => 'grand theft auto', 72 | 'category' => 'fork-curl', 73 | 'properties' => [], 74 | ])); 75 | } 76 | 77 | public function testAlias(): void 78 | { 79 | self::assertTrue($this->client->alias([ 80 | 'previousId' => 'previous-id', 81 | 'userId' => 'user-id', 82 | ])); 83 | } 84 | 85 | public function testRequestCompression(): void 86 | { 87 | $options = [ 88 | 'compress_request' => true, 89 | 'consumer' => 'fork_curl', 90 | 'debug' => true, 91 | ]; 92 | 93 | // Create client and send Track message 94 | $client = new Client('OnMMoZ6YVozrgSBeZ9FpkC0ixH0ycYZn', $options); 95 | $result = $client->track([ 96 | 'userId' => 'some-user', 97 | 'event' => "PHP Fork Curl'd\" Event with compression", 98 | ]); 99 | $client->__destruct(); 100 | 101 | self::assertTrue($result); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /send.php: -------------------------------------------------------------------------------- 1 | true, 67 | 'error_handler' => static function ($code, $msg) { 68 | print("$code: $msg\n"); 69 | exit(1); 70 | }, 71 | ]); 72 | 73 | /** 74 | * Payloads 75 | */ 76 | 77 | $total = 0; 78 | $successful = 0; 79 | foreach ($lines as $line) { 80 | if (!trim($line)) { 81 | continue; 82 | } 83 | $total++; 84 | $payload = json_decode($line, true); 85 | $dt = new DateTime($payload['timestamp']); 86 | $ts = (float)($dt->getTimestamp() . '.' . $dt->format('u')); 87 | $payload['timestamp'] = date('c', (int)$ts); 88 | $type = $payload['type']; 89 | $currentBatch[] = $payload; 90 | // flush before batch gets too big 91 | if (mb_strlen((json_encode(['batch' => $currentBatch, 'sentAt' => date('c')])), '8bit') >= 512000) { 92 | $libCurlResponse = Segment::flush(); 93 | if ($libCurlResponse) { 94 | $successful += count($currentBatch) - 1; 95 | //} else { 96 | // todo: maybe write batch to analytics-error.log for more controlled errorhandling 97 | } 98 | $currentBatch = []; 99 | } 100 | $payload['timestamp'] = $ts; 101 | call_user_func([Segment::class, $type], $payload); 102 | } 103 | 104 | $libCurlResponse = Segment::flush(); 105 | if ($libCurlResponse) { 106 | $successful += $total - $successful; 107 | } 108 | unlink($file); 109 | 110 | /** 111 | * Sent 112 | */ 113 | 114 | print("sent $successful from $total requests successfully"); 115 | exit(0); 116 | 117 | /** 118 | * Parse arguments 119 | */ 120 | 121 | function parse($argv): array 122 | { 123 | $ret = []; 124 | 125 | foreach ($argv as $i => $iValue) { 126 | $arg = $iValue; 127 | if (strpos($arg, '--') !== 0) { 128 | continue; 129 | } 130 | $ret[substr($arg, 2, strlen($arg))] = trim($argv[++$i]); 131 | } 132 | 133 | return $ret; 134 | } 135 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | 5 | Dariusz Rumiński 6 | This source file is subject to the MIT license that is bundled 7 | with this source code in the file LICENSE. 8 | EOF; 9 | $config = PhpCsFixer\Config::create() 10 | ->setIndent(" ") 11 | ->setLineEnding("\n") 12 | ->setUsingCache(false) 13 | ->setRiskyAllowed(true) 14 | ->setRules([ 15 | '@PHP56Migration' => false, 16 | '@PHPUnit60Migration:risky' => false, 17 | '@Symfony' => false, 18 | '@Symfony:risky' => false, 19 | 'align_multiline_comment' => true, 20 | 'array_syntax' => ['syntax' => 'long'], 21 | 'blank_line_before_statement' => true, 22 | 'combine_consecutive_issets' => true, 23 | 'combine_consecutive_unsets' => true, 24 | 'compact_nullable_typehint' => true, 25 | 'concat_space' => ['spacing' => 'one'], 26 | 'escape_implicit_backslashes' => true, 27 | 'explicit_indirect_variable' => true, 28 | 'explicit_string_variable' => true, 29 | 'final_internal_class' => true, 30 | 'heredoc_to_nowdoc' => true, 31 | 'list_syntax' => ['syntax' => 'long'], 32 | 'method_chaining_indentation' => true, 33 | 'method_argument_space' => ['ensure_fully_multiline' => true, 'keep_multiple_spaces_after_comma' => true], 34 | 'multiline_comment_opening_closing' => true, 35 | 'no_extra_blank_lines' => ['tokens' => ['break', 'continue', 'extra', 'return', 'throw', 'use', 'parenthesis_brace_block', 'square_brace_block', 'curly_brace_block']], 36 | 'no_null_property_initialization' => true, 37 | 'no_short_echo_tag' => true, 38 | 'no_superfluous_elseif' => true, 39 | 'no_unneeded_curly_braces' => true, 40 | 'no_unneeded_final_method' => true, 41 | 'no_unreachable_default_argument_value' => true, 42 | 'no_useless_else' => true, 43 | 'no_useless_return' => true, 44 | 'ordered_class_elements' => true, 45 | 'ordered_imports' => true, 46 | 'php_unit_strict' => true, 47 | 'php_unit_test_annotation' => true, 48 | 'php_unit_test_class_requires_covers' => false, 49 | 'phpdoc_add_missing_param_annotation' => true, 50 | 'phpdoc_order' => true, 51 | 'phpdoc_types_order' => true, 52 | 'semicolon_after_instruction' => true, 53 | 'single_line_comment_style' => true, 54 | 'single_quote' => false, 55 | 'strict_comparison' => false, 56 | 'strict_param' => false, 57 | 'yoda_style' => true, 58 | ]) 59 | ->setFinder( 60 | PhpCsFixer\Finder::create() 61 | ->exclude('tests/Fixtures') 62 | ->in(__DIR__) 63 | ) 64 | ; 65 | // special handling of fabbot.io service if it's using too old PHP CS Fixer version 66 | try { 67 | PhpCsFixer\FixerFactory::create() 68 | ->registerBuiltInFixers() 69 | ->registerCustomFixers($config->getCustomFixers()) 70 | ->useRuleSet(new PhpCsFixer\RuleSet($config->getRules())); 71 | } catch (PhpCsFixer\ConfigurationException\InvalidConfigurationException $e) { 72 | $config->setRules([]); 73 | } catch (UnexpectedValueException $e) { 74 | $config->setRules([]); 75 | } catch (InvalidArgumentException $e) { 76 | $config->setRules([]); 77 | } 78 | return $config; -------------------------------------------------------------------------------- /lib/Consumer/LibCurl.php: -------------------------------------------------------------------------------- 1 | payload($messages); 21 | $payload = json_encode($body); 22 | $secret = $this->secret; 23 | 24 | if ($this->compress_request) { 25 | $payload = gzencode($payload); 26 | } 27 | 28 | if ($this->host) { 29 | $host = $this->host; 30 | } else { 31 | $host = 'api.segment.io'; 32 | } 33 | $path = '/v1/batch'; 34 | $url = $this->protocol . $host . $path; 35 | 36 | $backoff = 100; // Set initial waiting time to 100ms 37 | 38 | while ($backoff < $this->maximum_backoff_duration) { 39 | // open connection 40 | $ch = curl_init(); 41 | 42 | // set the url, number of POST vars, POST data 43 | curl_setopt($ch, CURLOPT_USERPWD, $secret . ':'); 44 | curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); 45 | curl_setopt($ch, CURLOPT_TIMEOUT, $this->curl_timeout); 46 | curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->curl_connecttimeout); 47 | 48 | // set variables for headers 49 | $header = []; 50 | $header[] = 'Content-Type: application/json'; 51 | 52 | if ($this->compress_request) { 53 | $header[] = 'Content-Encoding: gzip'; 54 | } 55 | 56 | // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. 57 | $library = $messages[0]['context']['library']; 58 | $libName = $library['name']; 59 | $libVersion = $library['version']; 60 | $header[] = "User-Agent: $libName/$libVersion"; 61 | 62 | curl_setopt($ch, CURLOPT_HTTPHEADER, $header); 63 | curl_setopt($ch, CURLOPT_URL, $url); 64 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 65 | 66 | // retry failed requests just once to diminish impact on performance 67 | $responseContent = curl_exec($ch); 68 | 69 | $err = curl_error($ch); 70 | if ($err) { 71 | $this->handleError(curl_errno($ch), $err); 72 | return false; 73 | } 74 | 75 | $responseCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); 76 | 77 | //close connection 78 | curl_close($ch); 79 | 80 | if ($responseCode !== 200) { 81 | // log error 82 | $this->handleError($responseCode, $responseContent); 83 | 84 | if (($responseCode >= 500 && $responseCode <= 600) || $responseCode === 429) { 85 | // If status code is greater than 500 and less than 600, it indicates server error 86 | // Error code 429 indicates rate limited. 87 | // Retry uploading in these cases. 88 | usleep($backoff * 1000); 89 | $backoff *= 2; 90 | } elseif ($responseCode >= 400) { 91 | break; 92 | } 93 | } else { 94 | break; // no error 95 | } 96 | } 97 | 98 | return true; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/Consumer/File.php: -------------------------------------------------------------------------------- 1 | file_handle = @fopen($options['filename'], 'ab'); 31 | if ($this->file_handle === false) { 32 | $this->handleError(13, 'Failed to open analytics.log file'); 33 | return; 34 | } 35 | if (isset($options['filepermissions'])) { 36 | chmod($options['filename'], $options['filepermissions']); 37 | } else { 38 | chmod($options['filename'], 0644); 39 | } 40 | } 41 | 42 | public function __destruct() 43 | { 44 | if ( 45 | $this->file_handle && 46 | get_resource_type($this->file_handle) !== 'Unknown' 47 | ) { 48 | fclose($this->file_handle); 49 | } 50 | } 51 | 52 | /** 53 | * Tracks a user action 54 | * 55 | * @param array $message 56 | * @return bool whether the track call succeeded 57 | */ 58 | public function track(array $message): bool 59 | { 60 | return $this->write($message); 61 | } 62 | 63 | /** 64 | * Writes the API call to a file as line-delimited json 65 | * @param array $body post body content. 66 | * @return bool whether the request succeeded 67 | */ 68 | private function write(array $body): bool 69 | { 70 | if (!$this->file_handle) { 71 | return false; 72 | } 73 | 74 | $content = json_encode($body); 75 | $content .= "\n"; 76 | 77 | return fwrite($this->file_handle, $content) === strlen($content); 78 | } 79 | 80 | /** 81 | * Tags traits about the user. 82 | * 83 | * @param array $message 84 | * @return bool whether the identify call succeeded 85 | */ 86 | public function identify(array $message): bool 87 | { 88 | return $this->write($message); 89 | } 90 | 91 | /** 92 | * Tags traits about the group. 93 | * 94 | * @param array $message 95 | * @return bool whether the group call succeeded 96 | */ 97 | public function group(array $message): bool 98 | { 99 | return $this->write($message); 100 | } 101 | 102 | /** 103 | * Tracks a page view. 104 | * 105 | * @param array $message 106 | * @return bool whether the page call succeeded 107 | */ 108 | public function page(array $message): bool 109 | { 110 | return $this->write($message); 111 | } 112 | 113 | /** 114 | * Tracks a screen view. 115 | * 116 | * @param array $message 117 | * @return bool whether the screen call succeeded 118 | */ 119 | public function screen(array $message): bool 120 | { 121 | return $this->write($message); 122 | } 123 | 124 | /** 125 | * Aliases from one user id to another 126 | * 127 | * @param array $message 128 | * @return bool whether the alias call succeeded 129 | */ 130 | public function alias(array $message): bool 131 | { 132 | return $this->write($message); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /test/ConsumerLibCurlTest.php: -------------------------------------------------------------------------------- 1 | client = new Client( 19 | 'oq0vdlg7yi', 20 | [ 21 | 'consumer' => 'lib_curl', 22 | 'debug' => true, 23 | ] 24 | ); 25 | } 26 | 27 | public function testTrack(): void 28 | { 29 | self::assertTrue($this->client->track([ 30 | 'userId' => 'lib-curl-track', 31 | 'event' => "PHP Lib Curl'd\" Event", 32 | ])); 33 | } 34 | 35 | public function testIdentify(): void 36 | { 37 | self::assertTrue($this->client->identify([ 38 | 'userId' => 'lib-curl-identify', 39 | 'traits' => [ 40 | 'loves_php' => false, 41 | 'type' => 'consumer lib-curl test', 42 | 'birthday' => time(), 43 | ], 44 | ])); 45 | } 46 | 47 | public function testGroup(): void 48 | { 49 | self::assertTrue($this->client->group([ 50 | 'userId' => 'lib-curl-group', 51 | 'groupId' => 'group-id', 52 | 'traits' => [ 53 | 'type' => 'consumer lib-curl test', 54 | ], 55 | ])); 56 | } 57 | 58 | public function testPage(): void 59 | { 60 | self::assertTrue($this->client->page([ 61 | 'userId' => 'lib-curl-page', 62 | 'name' => 'analytics-php', 63 | 'category' => 'fork-curl', 64 | 'properties' => ['url' => 'https://a.url/'], 65 | ])); 66 | } 67 | 68 | public function testScreen(): void 69 | { 70 | self::assertTrue($this->client->page([ 71 | 'anonymousId' => 'lib-curl-screen', 72 | 'name' => 'grand theft auto', 73 | 'category' => 'fork-curl', 74 | 'properties' => [], 75 | ])); 76 | } 77 | 78 | public function testAlias(): void 79 | { 80 | self::assertTrue($this->client->alias([ 81 | 'previousId' => 'lib-curl-alias', 82 | 'userId' => 'user-id', 83 | ])); 84 | } 85 | 86 | public function testRequestCompression(): void 87 | { 88 | $options = [ 89 | 'compress_request' => true, 90 | 'consumer' => 'lib_curl', 91 | 'error_handler' => function ($errno, $errmsg) { 92 | throw new RuntimeException($errmsg, $errno); 93 | }, 94 | ]; 95 | 96 | $client = new Client('oq0vdlg7yi', $options); 97 | 98 | # Should error out with debug on. 99 | self::assertTrue($client->track(['user_id' => 'some-user', 'event' => 'Socket PHP Event'])); 100 | $client->__destruct(); 101 | } 102 | 103 | public function testLargeMessageSizeError(): void 104 | { 105 | $options = [ 106 | 'debug' => true, 107 | 'consumer' => 'lib_curl', 108 | ]; 109 | 110 | $client = new Client('testlargesize', $options); 111 | 112 | $big_property = str_repeat('a', 32 * 1024); 113 | 114 | self::assertFalse( 115 | $client->track( 116 | [ 117 | 'userId' => 'some-user', 118 | 'event' => 'Super Large PHP Event', 119 | 'properties' => ['big_property' => $big_property], 120 | ] 121 | ) && $client->flush() 122 | ); 123 | 124 | $client->__destruct(); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: "Tests" 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | jobs: 9 | 10 | coding-standard: 11 | runs-on: ubuntu-22.04 12 | name: Coding standards 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: '8.1' 22 | coverage: none 23 | tools: cs2pr 24 | 25 | # Install dependencies and handle caching in one go. 26 | # @link https://github.com/marketplace/actions/install-composer-dependencies 27 | - name: Install Composer dependencies 28 | uses: "ramsey/composer-install@v2" 29 | 30 | - name: Check coding standards 31 | continue-on-error: true 32 | run: ./vendor/bin/phpcs -s --report-full --report-checkstyle=./phpcs-report.xml 33 | 34 | - name: Show PHPCS results in PR 35 | run: cs2pr ./phpcs-report.xml 36 | 37 | lint: 38 | runs-on: ubuntu-22.04 39 | strategy: 40 | matrix: 41 | php: ['7.4', '8.0', '8.1', '8.2', '8.3'] 42 | experimental: [false] 43 | 44 | name: "Lint: PHP ${{ matrix.php }}" 45 | continue-on-error: ${{ matrix.experimental }} 46 | 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v4 50 | 51 | - name: Install PHP 52 | uses: shivammathur/setup-php@v2 53 | with: 54 | php-version: ${{ matrix.php }} 55 | coverage: none 56 | tools: cs2pr 57 | 58 | # Install dependencies and handle caching in one go. 59 | # @link https://github.com/marketplace/actions/install-composer-dependencies 60 | - name: Install Composer dependencies 61 | uses: "ramsey/composer-install@v2" 62 | 63 | - name: Lint against parse errors 64 | if: ${{ matrix.php != '8.1' }} 65 | run: composer lint -- --checkstyle | cs2pr 66 | 67 | - name: Lint against parse errors (PHP 8.1) 68 | if: ${{ matrix.php == '8.1' }} 69 | run: composer lint 70 | 71 | test: 72 | needs: ['coding-standard', 'lint'] 73 | runs-on: ubuntu-22.04 74 | strategy: 75 | matrix: 76 | php: ['7.4', '8.0', '8.1', '8.2', '8.3'] 77 | coverage: [false] 78 | experimental: [false] 79 | include: 80 | # Run code coverage on high/low PHP. 81 | - php: '7.4' 82 | coverage: true 83 | experimental: false 84 | - php: '8.0' 85 | coverage: true 86 | experimental: false 87 | - php: '8.1' 88 | coverage: true 89 | experimental: false 90 | - php: '8.2' 91 | coverage: true 92 | experimental: false 93 | - php: '8.3' 94 | coverage: true 95 | experimental: false 96 | 97 | name: "Test: PHP ${{ matrix.php }}" 98 | 99 | continue-on-error: ${{ matrix.experimental }} 100 | 101 | steps: 102 | - name: Checkout repository 103 | uses: actions/checkout@v4 104 | 105 | - name: Set up PHP 106 | uses: shivammathur/setup-php@v2 107 | with: 108 | php-version: ${{ matrix.php }} 109 | coverage: ${{ matrix.coverage && 'xdebug' || 'none' }} 110 | ini-values: sendmail_path=/usr/sbin/sendmail -t -i, error_reporting=E_ALL, display_errors=On 111 | extensions: imap, mbstring, intl, ctype, filter, hash 112 | 113 | # Install dependencies and handle caching in one go. 114 | # @link https://github.com/marketplace/actions/install-composer-dependencies 115 | - name: Install dependencies 116 | if: ${{ matrix.php != '8.1' }} 117 | uses: "ramsey/composer-install@v2" 118 | 119 | - name: Install dependencies - ignore-platform-reqs 120 | if: ${{ matrix.php == '8.1' }} 121 | uses: "ramsey/composer-install@v2" 122 | with: 123 | composer-options: --ignore-platform-reqs 124 | 125 | - name: Run tests, no code coverage 126 | if: ${{ matrix.coverage }} 127 | run: ./vendor/bin/phpunit --no-coverage 128 | 129 | - name: Run tests with code coverage 130 | if: ${{ matrix.coverage }} 131 | run: ./vendor/bin/phpunit 132 | 133 | - name: Send coverage report to Codecov 134 | if: ${{ success() && matrix.coverage }} 135 | uses: codecov/codecov-action@v5 136 | with: 137 | files: ./build/logs/clover.xml 138 | fail_ci_if_error: true 139 | verbose: true 140 | env: 141 | CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} 142 | -------------------------------------------------------------------------------- /lib/Segment.php: -------------------------------------------------------------------------------- 1 | track($message); 51 | } 52 | 53 | /** 54 | * Check the client. 55 | * 56 | * @throws SegmentException 57 | */ 58 | private static function checkClient(): void 59 | { 60 | if (self::$client !== null) { 61 | return; 62 | } 63 | 64 | throw new SegmentException('Segment::init() must be called before any other tracking method.'); 65 | } 66 | 67 | /** 68 | * Validate common properties. 69 | * 70 | * @param array $message 71 | * @param string $type 72 | */ 73 | public static function validate(array $message, string $type): void 74 | { 75 | $userId = (array_key_exists('userId', $message) && (string)$message['userId'] !== ''); 76 | $anonId = !empty($message['anonymousId']); 77 | self::assert($userId || $anonId, "Segment::$type() requires userId or anonymousId"); 78 | } 79 | 80 | /** 81 | * Tags traits about the user. 82 | * 83 | * @param array $message 84 | * @return bool whether the call succeeded 85 | */ 86 | public static function identify(array $message): bool 87 | { 88 | self::checkClient(); 89 | $message['type'] = 'identify'; 90 | self::validate($message, 'identify'); 91 | 92 | return self::$client->identify($message); 93 | } 94 | 95 | /** 96 | * Tags traits about the group. 97 | * 98 | * @param array $message 99 | * @return bool whether the group call succeeded 100 | */ 101 | public static function group(array $message): bool 102 | { 103 | self::checkClient(); 104 | $groupId = !empty($message['groupId']); 105 | self::assert($groupId, 'Segment::group() expects a groupId'); 106 | self::validate($message, 'group'); 107 | 108 | return self::$client->group($message); 109 | } 110 | 111 | /** 112 | * Tracks a page view 113 | * 114 | * @param array $message 115 | * @return bool whether the page call succeeded 116 | */ 117 | public static function page(array $message): bool 118 | { 119 | self::checkClient(); 120 | self::validate($message, 'page'); 121 | 122 | return self::$client->page($message); 123 | } 124 | 125 | /** 126 | * Tracks a screen view 127 | * 128 | * @param array $message 129 | * @return bool whether the screen call succeeded 130 | */ 131 | public static function screen(array $message): bool 132 | { 133 | self::checkClient(); 134 | self::validate($message, 'screen'); 135 | 136 | return self::$client->screen($message); 137 | } 138 | 139 | /** 140 | * Aliases the user id from a temporary id to a permanent one 141 | * 142 | * @param array $message 143 | * @return bool whether the alias call succeeded 144 | */ 145 | public static function alias(array $message): bool 146 | { 147 | self::checkClient(); 148 | $userId = (array_key_exists('userId', $message) && (string)$message['userId'] !== ''); 149 | $previousId = (array_key_exists('previousId', $message) && (string)$message['previousId'] !== ''); 150 | self::assert($userId && $previousId, 'Segment::alias() requires both userId and previousId'); 151 | 152 | return self::$client->alias($message); 153 | } 154 | 155 | /** 156 | * Flush the client 157 | */ 158 | public static function flush(): bool 159 | { 160 | self::checkClient(); 161 | 162 | return self::$client->flush(); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | analytics-php 2 | ============== 3 | [![Test status](https://github.com/segmentio/analytics-php/workflows/Tests/badge.svg)](https://github.com/segmentio/analytics-php/actions) 4 | [![codecov.io](https://codecov.io/gh/segmentio/analytics-php/branch/master/graph/badge.svg?token=iORZpwmYmM)](https://codecov.io/gh/segmentio/analytics-php) 5 | 6 | analytics-php is a php client for [Segment](https://segment.com) 7 | 8 | ### ⚠️ Maintenance ⚠️ 9 | 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. 10 | 11 |
12 | 13 |

You can't fix what you can't measure

14 |
15 | 16 | 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. 17 | 18 | ## How to get started 19 | 1. **Collect analytics data** from your app(s). 20 | - The top 200 Segment companies collect data from 5+ source types (web, mobile, server, CRM, etc.). 21 | 2. **Send the data to analytics tools** (for example, Google Analytics, Amplitude, Mixpanel). 22 | - 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. 23 | 3. **Explore your data** by creating metrics (for example, new signups, retention cohorts, and revenue generation). 24 | - The best Segment companies use retention cohorts to measure product market fit. Netflix has 70% paid retention after 12 months, 30% after 7 years. 25 | 26 | [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). 27 | 28 | ### Why? 29 | 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. 30 | 31 | 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. 32 | 33 | 3. **Send data from anywhere**. Send Segment data from any device, and we'll transform and send it on to any tool. 34 | 35 | 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. 36 | 37 | For example, you can capture data on any app: 38 | ```js 39 | analytics.track('Order Completed', { price: 99.84 }) 40 | ``` 41 | Then, query the resulting data in SQL: 42 | ```sql 43 | select * from app.order_completed 44 | order by price desc 45 | ``` 46 | 47 | ### 🚀 Startup Program 48 |
49 | 50 |
51 | 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! 52 | 53 | ## Documentation 54 | 55 | Documentation is available at [segment.com/docs/sources/server/php](https://segment.com/docs/sources/server/php/) 56 | 57 | ## Releasing 58 | 59 | Run `make release VERSION=`. It should automatically tag and release in composer 60 | 61 | ## License 62 | 63 | ``` 64 | WWWWWW||WWWWWW 65 | W W W||W W W 66 | || 67 | ( OO )__________ 68 | / | \ 69 | /o o| MIT \ 70 | \___/||_||__||_|| * 71 | || || || || 72 | _||_|| _||_|| 73 | (__|__|(__|__| 74 | ``` 75 | 76 | (The MIT License) 77 | 78 | Copyright (c) 2014 Segment Inc. 79 | 80 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 81 | 82 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 83 | 84 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 85 | -------------------------------------------------------------------------------- /test/ConsumerFileTest.php: -------------------------------------------------------------------------------- 1 | clearLog(); 19 | 20 | $this->client = new Client( 21 | 'oq0vdlg7yi', 22 | [ 23 | 'consumer' => 'file', 24 | 'filename' => $this->filename, 25 | ] 26 | ); 27 | } 28 | 29 | private function clearLog(): void 30 | { 31 | if (file_exists($this->filename)) { 32 | unlink($this->filename); 33 | } 34 | } 35 | 36 | public function filename(): string 37 | { 38 | return $this->filename; 39 | } 40 | 41 | public function tearDown(): void 42 | { 43 | $this->clearLog(); 44 | } 45 | 46 | public function testTrack(): void 47 | { 48 | self::assertTrue($this->client->track([ 49 | 'userId' => 'some-user', 50 | 'event' => 'File PHP Event - Microtime', 51 | 'timestamp' => microtime(true), 52 | ])); 53 | $this->checkWritten('track'); 54 | } 55 | 56 | public function checkWritten($type): void 57 | { 58 | exec('wc -l ' . $this->filename, $output); 59 | $out = trim($output[0]); 60 | self::assertSame($out, '1 ' . $this->filename); 61 | $str = file_get_contents($this->filename); 62 | $json = json_decode(trim($str), false); 63 | self::assertSame($type, $json->type); 64 | unlink($this->filename); 65 | } 66 | 67 | public function testIdentify(): void 68 | { 69 | self::assertTrue($this->client->identify([ 70 | 'userId' => 'Calvin', 71 | 'traits' => [ 72 | 'loves_php' => false, 73 | 'type' => 'analytics.log', 74 | 'birthday' => time(), 75 | ], 76 | ])); 77 | $this->checkWritten('identify'); 78 | } 79 | 80 | public function testGroup(): void 81 | { 82 | self::assertTrue($this->client->group([ 83 | 'userId' => 'user-id', 84 | 'groupId' => 'group-id', 85 | 'traits' => [ 86 | 'type' => 'consumer analytics.log test', 87 | ], 88 | ])); 89 | } 90 | 91 | public function testPage(): void 92 | { 93 | self::assertTrue($this->client->page([ 94 | 'userId' => 'user-id', 95 | 'name' => 'analytics-php', 96 | 'category' => 'analytics.log', 97 | 'properties' => ['url' => 'https://a.url/'], 98 | ])); 99 | } 100 | 101 | public function testScreen(): void 102 | { 103 | self::assertTrue($this->client->screen([ 104 | 'userId' => 'userId', 105 | 'name' => 'grand theft auto', 106 | 'category' => 'analytics.log', 107 | 'properties' => [], 108 | ])); 109 | } 110 | 111 | public function testAlias(): void 112 | { 113 | self::assertTrue($this->client->alias([ 114 | 'previousId' => 'previous-id', 115 | 'userId' => 'user-id', 116 | ])); 117 | $this->checkWritten('alias'); 118 | } 119 | 120 | public function testSend(): void 121 | { 122 | for ($i = 0; $i < 200; ++$i) { 123 | $this->client->track([ 124 | 'userId' => 'userId', 125 | 'event' => 'event', 126 | ]); 127 | } 128 | exec('php --define date.timezone=UTC send.php --secret oq0vdlg7yi --file /tmp/analytics.log', $output); 129 | self::assertSame('sent 200 from 200 requests successfully', trim($output[0])); 130 | self::assertFileDoesNotExist($this->filename); 131 | } 132 | 133 | public function testProductionProblems(): void 134 | { 135 | // Open to a place where we should not have write access. 136 | $client = new Client( 137 | 'oq0vdlg7yi', 138 | [ 139 | 'consumer' => 'file', 140 | 'filename' => '/dev/xxxxxxx', 141 | ] 142 | ); 143 | 144 | $tracked = $client->track(['userId' => 'some-user', 'event' => 'my event']); 145 | self::assertFalse($tracked); 146 | } 147 | 148 | public function testFileSecurityCustom(): void 149 | { 150 | $client = new Client( 151 | 'testsecret', 152 | [ 153 | 'consumer' => 'file', 154 | 'filename' => $this->filename, 155 | 'filepermissions' => 0600, 156 | ] 157 | ); 158 | $client->track(['userId' => 'some_user', 'event' => 'File PHP Event']); 159 | self::assertEquals(0600, (fileperms($this->filename) & 0777)); 160 | } 161 | 162 | public function testFileSecurityDefaults(): void 163 | { 164 | $client = new Client( 165 | 'testsecret', 166 | [ 167 | 'consumer' => 'file', 168 | 'filename' => $this->filename, 169 | ] 170 | ); 171 | $client->track(['userId' => 'some_user', 'event' => 'File PHP Event']); 172 | self::assertEquals(0644, (fileperms($this->filename) & 0777)); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /lib/Consumer/Socket.php: -------------------------------------------------------------------------------- 1 | createSocket(); 41 | 42 | if (!$socket) { 43 | return false; 44 | } 45 | 46 | $payload = $this->payload($batch); 47 | $payload = json_encode($payload); 48 | 49 | $body = $this->createBody($this->options['host'], $payload); 50 | if ($body === false) { 51 | return false; 52 | } 53 | 54 | return $this->makeRequest($socket, $body); 55 | } 56 | 57 | /** 58 | * Open a connection to the target host. 59 | * 60 | * @return false|resource 61 | */ 62 | private function createSocket() 63 | { 64 | if ($this->socket_failed) { 65 | return false; 66 | } 67 | 68 | $protocol = $this->options['tls'] ? 'tls' : 'ssl'; 69 | $host = $this->options['host']; 70 | $port = 443; 71 | $timeout = $this->options['timeout']; 72 | 73 | // Open our socket to the API Server. 74 | $socket = @pfsockopen( 75 | $protocol . '://' . $host, 76 | $port, 77 | $errno, 78 | $errstr, 79 | $timeout 80 | ); 81 | 82 | // If we couldn't open the socket, handle the error. 83 | if ($socket === false) { 84 | $this->handleError($errno, $errstr); 85 | $this->socket_failed = true; 86 | } 87 | 88 | return $socket; 89 | } 90 | 91 | /** 92 | * Create the request body. 93 | * 94 | * @param string $host 95 | * @param string $content 96 | * @return string body 97 | */ 98 | private function createBody(string $host, string $content) 99 | { 100 | $req = "POST /v1/batch HTTP/1.1\r\n"; 101 | $req .= 'Host: ' . $host . "\r\n"; 102 | $req .= "Content-Type: application/json\r\n"; 103 | $req .= 'Authorization: Basic ' . base64_encode($this->secret . ':') . "\r\n"; 104 | $req .= "Accept: application/json\r\n"; 105 | 106 | // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. 107 | $content_json = json_decode($content, true); 108 | $library = $content_json['batch'][0]['context']['library']; 109 | $libName = $library['name']; 110 | $libVersion = $library['version']; 111 | $req .= "User-Agent: $libName/$libVersion\r\n"; 112 | 113 | // Compress content if compress_request is true 114 | if ($this->compress_request) { 115 | $content = gzencode($content); 116 | 117 | $req .= "Content-Encoding: gzip\r\n"; 118 | } 119 | 120 | $req .= 'Content-length: ' . strlen($content) . "\r\n"; 121 | $req .= "\r\n"; 122 | $req .= $content; 123 | 124 | // Verify payload size is below 512KB 125 | if (strlen($req) >= 500 * 1024) { 126 | $msg = 'Payload size is larger than 512KB'; 127 | /** @noinspection ForgottenDebugOutputInspection */ 128 | error_log('[Analytics][' . $this->type . '] ' . $msg); 129 | 130 | return false; 131 | } 132 | 133 | return $req; 134 | } 135 | 136 | /** 137 | * Attempt to write the request to the socket, wait for response if debug 138 | * mode is enabled. 139 | * 140 | * @param resource|false $socket the handle for the socket 141 | * @param string $req request body 142 | * @return bool 143 | */ 144 | private function makeRequest($socket, string $req): bool 145 | { 146 | $bytes_written = 0; 147 | $bytes_total = strlen($req); 148 | $closed = false; 149 | $success = true; 150 | 151 | // Retries with exponential backoff until success 152 | $backoff = 100; // Set initial waiting time to 100ms 153 | 154 | while (true) { 155 | // Send request to server 156 | while (!$closed && $bytes_written < $bytes_total) { 157 | $written = @fwrite($socket, substr($req, $bytes_written)); 158 | if ($written === false) { 159 | $this->handleError(13, 'Failed to write to socket.'); 160 | $closed = true; 161 | } else { 162 | $bytes_written += $written; 163 | } 164 | } 165 | 166 | // Get response for request 167 | $statusCode = 0; 168 | 169 | if (!$closed) { 170 | $res = self::parseResponse(fread($socket, 2048)); 171 | $statusCode = (int)$res['status']; 172 | } 173 | fclose($socket); 174 | 175 | // If status code is 200, return true 176 | if ($statusCode === 200) { 177 | return true; 178 | } 179 | 180 | // If status code is greater than 500 and less than 600, it indicates server error 181 | // Error code 429 indicates rate limited. 182 | // Retry uploading in these cases. 183 | if (($statusCode >= 500 && $statusCode <= 600) || $statusCode === 429 || $statusCode === 0) { 184 | if ($backoff >= $this->maximum_backoff_duration) { 185 | break; 186 | } 187 | 188 | usleep($backoff * 1000); 189 | } elseif ($statusCode >= 400) { 190 | if ($this->debug()) { 191 | $this->handleError($res['status'], $res['message']); 192 | } 193 | 194 | break; 195 | } 196 | 197 | // Retry uploading... 198 | $backoff *= 2; 199 | $socket = $this->createSocket(); 200 | } 201 | 202 | return $success; 203 | } 204 | 205 | /** 206 | * Parse our response from the server, check header and body. 207 | * @param string $res 208 | * @return array 209 | * string $status HTTP code, e.g. "200" 210 | * string $message JSON response from the api 211 | */ 212 | private static function parseResponse(string $res): array 213 | { 214 | [$first,] = explode("\n", $res, 2); 215 | 216 | // Response comes back as HTTP/1.1 200 OK 217 | // Final line contains HTTP response. 218 | [, $status, $message] = explode(' ', $first, 3); 219 | 220 | return [ 221 | 'status' => $status ?? null, 222 | 'message' => $message, 223 | ]; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /test/ConsumerSocketTest.php: -------------------------------------------------------------------------------- 1 | client = new Client( 20 | 'oq0vdlg7yi', 21 | ['consumer' => 'socket'] 22 | ); 23 | } 24 | 25 | public function testTrack(): void 26 | { 27 | self::assertTrue( 28 | $this->client->track( 29 | [ 30 | 'userId' => 'some-user', 31 | 'event' => 'Socket PHP Event', 32 | ] 33 | ) 34 | ); 35 | } 36 | 37 | public function testIdentify(): void 38 | { 39 | self::assertTrue( 40 | $this->client->identify( 41 | [ 42 | 'userId' => 'Calvin', 43 | 'traits' => [ 44 | 'loves_php' => false, 45 | 'birthday' => time(), 46 | ], 47 | ] 48 | ) 49 | ); 50 | } 51 | 52 | public function testGroup(): void 53 | { 54 | self::assertTrue( 55 | $this->client->group( 56 | [ 57 | 'userId' => 'user-id', 58 | 'groupId' => 'group-id', 59 | 'traits' => [ 60 | 'type' => 'consumer socket test', 61 | ], 62 | ] 63 | ) 64 | ); 65 | } 66 | 67 | public function testPage(): void 68 | { 69 | self::assertTrue( 70 | $this->client->page( 71 | [ 72 | 'userId' => 'user-id', 73 | 'name' => 'analytics-php', 74 | 'category' => 'socket', 75 | 'properties' => ['url' => 'https://a.url/'], 76 | ] 77 | ) 78 | ); 79 | } 80 | 81 | public function testScreen(): void 82 | { 83 | self::assertTrue( 84 | $this->client->screen( 85 | [ 86 | 'anonymousId' => 'anonymousId', 87 | 'name' => 'grand theft auto', 88 | 'category' => 'socket', 89 | 'properties' => [], 90 | ] 91 | ) 92 | ); 93 | } 94 | 95 | public function testAlias(): void 96 | { 97 | self::assertTrue( 98 | $this->client->alias( 99 | [ 100 | 'previousId' => 'some-socket', 101 | 'userId' => 'new-socket', 102 | ] 103 | ) 104 | ); 105 | } 106 | 107 | public function testShortTimeout(): void 108 | { 109 | $client = new Client( 110 | 'oq0vdlg7yi', 111 | [ 112 | 'timeout' => 0.01, 113 | 'consumer' => 'socket', 114 | ] 115 | ); 116 | 117 | self::assertTrue( 118 | $client->track( 119 | [ 120 | 'userId' => 'some-user', 121 | 'event' => 'Socket PHP Event', 122 | ] 123 | ) 124 | ); 125 | 126 | self::assertTrue( 127 | $client->identify( 128 | [ 129 | 'userId' => 'some-user', 130 | 'traits' => [], 131 | ] 132 | ) 133 | ); 134 | 135 | $client->__destruct(); 136 | } 137 | 138 | public function testProductionProblems(): void 139 | { 140 | $client = new Client( 141 | 'x', 142 | [ 143 | 'consumer' => 'socket', 144 | 'error_handler' => function () { 145 | throw new Exception('Was called'); 146 | }, 147 | ] 148 | ); 149 | 150 | // Shouldn't error out without debug on. 151 | self::assertTrue($client->track(['user_id' => 'some-user', 'event' => 'Production Problems'])); 152 | $client->__destruct(); 153 | } 154 | 155 | public function testDebugProblems(): void 156 | { 157 | $options = [ 158 | 'debug' => true, 159 | 'consumer' => 'socket', 160 | 'error_handler' => function ($errno, $errmsg) { 161 | if ($errno !== 400) { 162 | throw new Exception('Response is not 400'); 163 | } 164 | }, 165 | ]; 166 | 167 | $client = new Client('oq0vdlg7yi', $options); 168 | 169 | // Should error out with debug on. 170 | self::assertTrue($client->track(['user_id' => 'some-user', 'event' => 'Socket PHP Event'])); 171 | $client->__destruct(); 172 | } 173 | 174 | public function testLargeMessage(): void 175 | { 176 | $options = [ 177 | 'debug' => true, 178 | 'consumer' => 'socket', 179 | ]; 180 | 181 | $client = new Client('testsecret', $options); 182 | 183 | $big_property = str_repeat('a', 10000); 184 | 185 | self::assertTrue( 186 | $client->track( 187 | [ 188 | 'userId' => 'some-user', 189 | 'event' => 'Super Large PHP Event', 190 | 'properties' => ['big_property' => $big_property], 191 | ] 192 | ) 193 | ); 194 | 195 | $client->__destruct(); 196 | } 197 | 198 | public function testLargeMessageSizeError(): void 199 | { 200 | $options = [ 201 | 'debug' => true, 202 | 'consumer' => 'socket', 203 | ]; 204 | 205 | $client = new Client('testlargesize', $options); 206 | 207 | $big_property = str_repeat('a', 32 * 1024); 208 | 209 | self::assertFalse( 210 | $client->track( 211 | [ 212 | 'userId' => 'some-user', 213 | 'event' => 'Super Large PHP Event', 214 | 'properties' => ['big_property' => $big_property], 215 | ] 216 | ) && $client->flush() 217 | ); 218 | 219 | $client->__destruct(); 220 | } 221 | 222 | public function testConnectionError(): void 223 | { 224 | $this->expectException(RuntimeException::class); 225 | $client = new Client( 226 | 'x', 227 | [ 228 | 'consumer' => 'socket', 229 | 'host' => 'api.segment.ioooooo', 230 | 'error_handler' => function ($errno, $errmsg) { 231 | throw new RuntimeException($errmsg, $errno); 232 | }, 233 | ] 234 | ); 235 | 236 | $client->track(['user_id' => 'some-user', 'event' => 'Event']); 237 | $client->__destruct(); 238 | } 239 | 240 | public function testRequestCompression(): void 241 | { 242 | $options = [ 243 | 'compress_request' => true, 244 | 'consumer' => 'socket', 245 | 'error_handler' => function ($errno, $errmsg) { 246 | throw new RuntimeException($errmsg, $errno); 247 | }, 248 | ]; 249 | 250 | $client = new Client('x', $options); 251 | 252 | # Should error out with debug on. 253 | self::assertTrue($client->track(['user_id' => 'some-user', 'event' => 'Socket PHP Event'])); 254 | $client->__destruct(); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /lib/Consumer/QueueConsumer.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | protected array $queue; 16 | protected int $max_queue_size = 10000; 17 | protected int $max_queue_size_bytes = 33554432; //32M 18 | protected int $flush_at = 100; 19 | protected int $max_batch_size_bytes = 512000; //500kb 20 | protected int $max_item_size_bytes = 32000; // 32kb 21 | protected int $maximum_backoff_duration = 10000; // Set maximum waiting limit to 10s 22 | protected string $host = ''; 23 | protected bool $compress_request = false; 24 | protected int $flush_interval_in_mills = 10000; //frequency in milliseconds to send data, default 10 25 | protected int $curl_timeout = 0; // by default this is infinite 26 | protected int $curl_connecttimeout = 300; 27 | 28 | /** 29 | * Store our secret and options as part of this consumer 30 | * @param string $secret 31 | * @param array $options 32 | */ 33 | public function __construct(string $secret, array $options = []) 34 | { 35 | parent::__construct($secret, $options); 36 | 37 | if (isset($options['max_queue_size'])) { 38 | $this->max_queue_size = $options['max_queue_size']; 39 | } 40 | 41 | if (isset($options['batch_size'])) { 42 | if ($options['batch_size'] < 1) { 43 | $msg = 'Batch Size must not be less than 1'; 44 | error_log('[Analytics][' . $this->type . '] ' . $msg); 45 | } else { 46 | $msg = 'WARNING: batch_size option to be deprecated soon, please use new option flush_at'; 47 | error_log('[Analytics][' . $this->type . '] ' . $msg); 48 | $this->flush_at = $options['batch_size']; 49 | } 50 | } 51 | 52 | if (isset($options['flush_at'])) { 53 | if ($options['flush_at'] < 1) { 54 | $msg = 'Flush at Size must not be less than 1'; 55 | error_log('[Analytics][' . $this->type . '] ' . $msg); 56 | } else { 57 | $this->flush_at = $options['flush_at']; 58 | } 59 | } 60 | 61 | if (isset($options['host'])) { 62 | $this->host = $options['host']; 63 | } 64 | 65 | if (isset($options['compress_request'])) { 66 | $this->compress_request = (bool)$options['compress_request']; 67 | } 68 | 69 | if (isset($options['flush_interval'])) { 70 | if ($options['flush_interval'] < 1000) { 71 | $msg = 'Flush interval must not be less than 1 second'; 72 | error_log('[Analytics][' . $this->type . '] ' . $msg); 73 | } else { 74 | $this->flush_interval_in_mills = $options['flush_interval']; 75 | } 76 | } 77 | 78 | if (isset($options['curl_timeout'])) { 79 | $this->curl_timeout = $options['curl_timeout']; 80 | } 81 | 82 | if (isset($options['curl_connecttimeout'])) { 83 | $this->curl_connecttimeout = $options['curl_connecttimeout']; 84 | } 85 | 86 | $this->queue = []; 87 | } 88 | 89 | public function __destruct() 90 | { 91 | // Flush our queue on destruction 92 | $this->flush(); 93 | } 94 | 95 | /** 96 | * Flushes our queue of messages by batching them to the server 97 | */ 98 | public function flush(): bool 99 | { 100 | $count = count($this->queue); 101 | $success = true; 102 | 103 | while ($count > 0 && $success) { 104 | $batch = array_splice($this->queue, 0, min($this->flush_at, $count)); 105 | 106 | if (mb_strlen(serialize($batch), '8bit') >= $this->max_batch_size_bytes) { 107 | $msg = 'Batch size is larger than 500KB'; 108 | error_log('[Analytics][' . $this->type . '] ' . $msg); 109 | 110 | return false; 111 | } 112 | 113 | $success = $this->flushBatch($batch); 114 | 115 | $count = count($this->queue); 116 | 117 | if ($count > 0) { 118 | usleep($this->flush_interval_in_mills * 1000); 119 | } 120 | } 121 | 122 | return $success; 123 | } 124 | 125 | /** 126 | * Tracks a user action 127 | * 128 | * @param array $message 129 | * @return bool whether the track call succeeded 130 | */ 131 | public function track(array $message): bool 132 | { 133 | return $this->enqueue($message); 134 | } 135 | 136 | /** 137 | * Adds an item to our queue. 138 | * @param mixed $item 139 | * @return bool whether call has succeeded 140 | */ 141 | protected function enqueue($item): bool 142 | { 143 | $count = count($this->queue); 144 | 145 | if ($count > $this->max_queue_size) { 146 | return false; 147 | } 148 | 149 | if (mb_strlen(serialize($this->queue), '8bit') >= $this->max_queue_size_bytes) { 150 | $msg = 'Queue size is larger than 32MB'; 151 | error_log('[Analytics][' . $this->type . '] ' . $msg); 152 | 153 | return false; 154 | } 155 | 156 | if (mb_strlen(json_encode($item), '8bit') >= $this->max_item_size_bytes) { 157 | $msg = 'Item size is larger than 32KB'; 158 | error_log('[Analytics][' . $this->type . '] ' . $msg); 159 | 160 | return false; 161 | } 162 | 163 | $count = array_push($this->queue, $item); 164 | 165 | if ($count >= $this->flush_at) { 166 | return $this->flush(); 167 | } 168 | 169 | return true; 170 | } 171 | 172 | /** 173 | * Tags traits about the user. 174 | * 175 | * @param array $message 176 | * @return bool whether the identify call succeeded 177 | */ 178 | public function identify(array $message): bool 179 | { 180 | return $this->enqueue($message); 181 | } 182 | 183 | /** 184 | * Tags traits about the group. 185 | * 186 | * @param array $message 187 | * @return bool whether the group call succeeded 188 | */ 189 | public function group(array $message): bool 190 | { 191 | return $this->enqueue($message); 192 | } 193 | 194 | /** 195 | * Tracks a page view. 196 | * 197 | * @param array $message 198 | * @return bool whether the page call succeeded 199 | */ 200 | public function page(array $message): bool 201 | { 202 | return $this->enqueue($message); 203 | } 204 | 205 | /** 206 | * Tracks a screen view. 207 | * 208 | * @param array $message 209 | * @return bool whether the screen call succeeded 210 | */ 211 | public function screen(array $message): bool 212 | { 213 | return $this->enqueue($message); 214 | } 215 | 216 | /** 217 | * Aliases from one user id to another 218 | * 219 | * @param array $message 220 | * @return bool whether the alias call succeeded 221 | */ 222 | public function alias(array $message): bool 223 | { 224 | return $this->enqueue($message); 225 | } 226 | 227 | /** 228 | * Given a batch of messages the method returns 229 | * a valid payload. 230 | * 231 | * @param array $batch 232 | * @return array 233 | */ 234 | protected function payload(array $batch): array 235 | { 236 | return [ 237 | 'batch' => $batch, 238 | 'sentAt' => date('c'), 239 | ]; 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /lib/Client.php: -------------------------------------------------------------------------------- 1 | Socket::class, 31 | 'file' => File::class, 32 | 'fork_curl' => ForkCurl::class, 33 | 'lib_curl' => LibCurl::class, 34 | ]; 35 | // Use our socket libcurl by default 36 | $consumer_type = $options['consumer'] ?? 'lib_curl'; 37 | 38 | if (!array_key_exists($consumer_type, $consumers) && class_exists($consumer_type)) { 39 | if (!is_subclass_of($consumer_type, Consumer::class)) { 40 | throw new SegmentException('Consumers must extend the Segment/Consumer/Consumer abstract class'); 41 | } 42 | // Try to resolve it by class name 43 | $this->consumer = new $consumer_type($secret, $options); 44 | return; 45 | } 46 | 47 | $Consumer = $consumers[$consumer_type]; 48 | 49 | $this->consumer = new $Consumer($secret, $options); 50 | } 51 | 52 | public function __destruct() 53 | { 54 | $this->consumer->__destruct(); 55 | } 56 | 57 | /** 58 | * Tracks a user action 59 | * 60 | * @param array $message 61 | * @return bool whether the track call succeeded 62 | */ 63 | public function track(array $message): bool 64 | { 65 | $message = $this->message($message, 'properties'); 66 | $message['type'] = 'track'; 67 | 68 | return $this->consumer->track($message); 69 | } 70 | 71 | /** 72 | * Add common fields to the given `message` 73 | * 74 | * @param array $msg 75 | * @param string $def 76 | * @return array 77 | */ 78 | 79 | private function message(array $msg, string $def = ''): array 80 | { 81 | if ($def && !isset($msg[$def])) { 82 | $msg[$def] = []; 83 | } 84 | if ($def && empty($msg[$def])) { 85 | $msg[$def] = (object)$msg[$def]; 86 | } 87 | 88 | if (!isset($msg['context'])) { 89 | $msg['context'] = []; 90 | } 91 | $msg['context'] = array_merge($this->getDefaultContext(), $msg['context']); 92 | 93 | if (!isset($msg['timestamp'])) { 94 | $msg['timestamp'] = null; 95 | } 96 | $msg['timestamp'] = $this->formatTime((int)$msg['timestamp']); 97 | 98 | if (!isset($msg['messageId'])) { 99 | $msg['messageId'] = self::messageId(); 100 | } 101 | 102 | return $msg; 103 | } 104 | 105 | /** 106 | * Add the segment.io context to the request 107 | * @return array additional context 108 | */ 109 | private function getDefaultContext(): array 110 | { 111 | require __DIR__ . '/Version.php'; 112 | 113 | global $SEGMENT_VERSION; 114 | 115 | return [ 116 | 'library' => [ 117 | 'name' => 'analytics-php', 118 | 'version' => $SEGMENT_VERSION, 119 | 'consumer' => $this->consumer->getConsumer(), 120 | ], 121 | ]; 122 | } 123 | 124 | /** 125 | * Formats a timestamp by making sure it is set 126 | * and converting it to iso8601. 127 | * 128 | * The timestamp can be time in seconds `time()` or `microtime(true)`. 129 | * any other input is considered an error and the method will return a new date. 130 | * 131 | * Note: php's date() "u" format (for microseconds) has a bug in it 132 | * it always shows `.000` for microseconds since `date()` only accepts 133 | * ints, so we have to construct the date ourselves if microtime is passed. 134 | * 135 | * @param mixed $timestamp time in seconds (time()) or a time expression string 136 | */ 137 | private function formatTime($ts) 138 | { 139 | if (!$ts) { 140 | $ts = time(); 141 | } 142 | if (filter_var($ts, FILTER_VALIDATE_INT) !== false) { 143 | return date('c', (int)$ts); 144 | } 145 | 146 | // anything else try to strtotime the date. 147 | if (filter_var($ts, FILTER_VALIDATE_FLOAT) === false) { 148 | if (is_string($ts)) { 149 | return date('c', strtotime($ts)); 150 | } 151 | 152 | return date('c'); 153 | } 154 | 155 | // fix for floatval casting in send.php 156 | $parts = explode('.', (string)$ts); 157 | if (!isset($parts[1])) { 158 | return date('c', (int)$parts[0]); 159 | } 160 | 161 | $fmt = sprintf('Y-m-d\TH:i:s.%sP', $parts[1]); 162 | 163 | return date($fmt, (int)$parts[0]); 164 | } 165 | 166 | /** 167 | * Generate a random messageId. 168 | * 169 | * https://gist.github.com/dahnielson/508447#file-uuid-php-L74 170 | * 171 | * @return string 172 | */ 173 | 174 | private static function messageId(): string 175 | { 176 | return sprintf( 177 | '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', 178 | mt_rand(0, 0xffff), 179 | mt_rand(0, 0xffff), 180 | mt_rand(0, 0xffff), 181 | mt_rand(0, 0x0fff) | 0x4000, 182 | mt_rand(0, 0x3fff) | 0x8000, 183 | mt_rand(0, 0xffff), 184 | mt_rand(0, 0xffff), 185 | mt_rand(0, 0xffff) 186 | ); 187 | } 188 | 189 | /** 190 | * Tags traits about the user. 191 | * 192 | * @param array $message 193 | * @return bool whether the track call succeeded 194 | */ 195 | public function identify(array $message): bool 196 | { 197 | $message = $this->message($message, 'traits'); 198 | $message['type'] = 'identify'; 199 | 200 | return $this->consumer->identify($message); 201 | } 202 | 203 | /** 204 | * Tags traits about the group. 205 | * 206 | * @param array $message 207 | * @return bool whether the group call succeeded 208 | */ 209 | public function group(array $message): bool 210 | { 211 | $message = $this->message($message, 'traits'); 212 | $message['type'] = 'group'; 213 | 214 | return $this->consumer->group($message); 215 | } 216 | 217 | /** 218 | * Tracks a page view. 219 | * 220 | * @param array $message 221 | * @return bool whether the page call succeeded 222 | */ 223 | public function page(array $message): bool 224 | { 225 | $message = $this->message($message, 'properties'); 226 | $message['type'] = 'page'; 227 | 228 | return $this->consumer->page($message); 229 | } 230 | 231 | /** 232 | * Tracks a screen view. 233 | * 234 | * @param array $message 235 | * @return bool whether the screen call succeeded 236 | */ 237 | public function screen(array $message): bool 238 | { 239 | $message = $this->message($message, 'properties'); 240 | $message['type'] = 'screen'; 241 | 242 | return $this->consumer->screen($message); 243 | } 244 | 245 | /** 246 | * Aliases from one user id to another 247 | * 248 | * @param array $message 249 | * @return bool whether the alias call succeeded 250 | */ 251 | public function alias(array $message): bool 252 | { 253 | $message = $this->message($message); 254 | $message['type'] = 'alias'; 255 | 256 | return $this->consumer->alias($message); 257 | } 258 | 259 | /** 260 | * Flush any async consumers 261 | * @return bool true if flushed successfully 262 | */ 263 | public function flush(): bool 264 | { 265 | if (method_exists($this->consumer, 'flush')) { 266 | return $this->consumer->flush(); 267 | } 268 | 269 | return true; 270 | } 271 | 272 | /** 273 | * @return Consumer 274 | */ 275 | public function getConsumer(): Consumer 276 | { 277 | return $this->consumer; 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | 2 | 3.8.1 / 2025-01-27 3 | ================== 4 | 5 | * Convert the exec output to string (#239) 6 | 7 | 3.8.0 / 2024-02-15 8 | ================== 9 | 10 | * Include support for 8.3 (#231) 11 | 12 | 13 | 3.7.0 / 2023-09-11 14 | ================== 15 | 16 | * Convert to github actions 17 | * Remove circleci config/files 18 | * Update autoloader 19 | 20 | 21 | 3.6.0 / 2023-03-28 22 | ================== 23 | 24 | * Issue #208 Correct autoload require statement (#209) 25 | * Fix missing version information (#207) 26 | * Include support for 8.1 * 8.2 (#210) 27 | 28 | 29 | 3.5.0 / 2022-08-17 30 | ================== 31 | 32 | * Correct Payload size check of 512kb (#202) 33 | * Add new consumer configurable options: curl_timeout, curl_connecttimeout, max_item_size_bytes, max_queue_size_bytes (#192, #197, #198) 34 | * Deprecate HTTP Option (#194 & #195) 35 | 36 | 37 | 3.0.0 / 2021-10-14 38 | ================== 39 | 40 | * PSR-12 coding standard with Slevomat and phcs extensions 41 | * Namespace and file rearrangement to follow PSR-4 naming scheme and more logical separation 42 | * Provide strict types for all properties, parameters, and return values 43 | * Add an exception class so we can have segment-specific exceptions 44 | * Add dependencies on JSON extension 45 | * Add dependency on the Roave security checker 46 | * 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 47 | * More sensible error handling, don't try to catch exceptions that are never thrown 48 | * Extensive linting and static analysis using phpcs, psalm, phpstan, and PHPStorm to spot issues 49 | 50 | 51 | 2.0.0 / 2021-07-16 52 | ================== 53 | 54 | * Modify Endpoint to match API docs (#171) 55 | * usleep in flush() causes unexpected delays on page loads (#173) 56 | * Support PHP 8 (#152) 57 | * Remove Support for PHP 7.2 58 | * Namespacing (#182) 59 | 60 | 61 | 1.8.0 / 2021-05-31 62 | ================== 63 | 64 | * Fix socket return response (#174) 65 | * API Endpoint update (#168) 66 | * Update Batch Size Check (#168) 67 | * Remove messageID override capabilities (#163) 68 | * Update flush sleep waiting period (#161) 69 | 70 | 1.7.0 / 2021-03-10 71 | ======================= 72 | 73 | * Retry Network errors (#136) 74 | * Update Tests [Improvement] (#132) 75 | * Updtate Readme Status Badging (#139) 76 | * Bump e2e tests to latest version [Improvement] (#142) 77 | * Add Limits to message, batch and memory usage [Feature] (#137) 78 | * Add Configurable flush parameters [Feature] (#135) 79 | * Add ability to use custom consumer [Feature] (#61) 80 | * Add ability to set file permissions [Feature] (#122) 81 | * Fix curl error handler [Improvement] (#97) 82 | * Fix timestamp implementation for microseconds (#94) 83 | * Modify max queue size setting to match requirements (#153, #146) 84 | * Add ability to set userid as zero (#157) 85 | 86 | 87 | 1.6.1-beta / 2018-05-01 88 | ======================= 89 | 90 | * Fix tslint error in version.php 91 | 92 | 1.6.0-beta / 2018-04-30 93 | ======================= 94 | 95 | * Add License file 96 | * Coding style fixers (#112) 97 | * rename type to method to match new harness contract (#110) 98 | * Increase Code coverage (#108) 99 | * Add Linter to CI (#109) 100 | * When the message size is larger than 32KB, return FALSE instead of throw exception 101 | * Make writeKey required as a flag in the CLI instead of as an environment variable. 102 | * Verify message size is below than 32KB 103 | * Run E2E test when RUN_E2E_TESTS is defined 104 | * Add Rfc 7231 compliant user agent into request header 105 | * Add backoff for socket communication 106 | * Implement response error handling for POST request and add backoff (in LibCurl) 107 | * Change environment to precise as default 108 | * CI: Make PHP 5.3 test to be run in precise environment 109 | * Make host to be configurable 110 | * Add anonymousId in group payload 111 | 112 | 1.5.2 / 2017-08-18 113 | ================== 114 | 115 | * Always set default context. 116 | 117 | 1.5.1 / 2017-04-06 118 | ================== 119 | 120 | * 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. 121 | 122 | 1.5.0 / 2017-03-03 123 | ================== 124 | 125 | * Adding context.library.consumer to all PHP events 126 | * libcurl consumer will retry once if http response is not 200 127 | * update link to php docs 128 | * improve portability and reliability of Makefile across different platforms (#74) 129 | 130 | 1.4.2 / 2016-07-11 131 | ================== 132 | 133 | * remove the extra -e from echo in makefile 134 | 135 | 1.4.1 / 2016-07-11 136 | ================== 137 | 138 | * use a more portable shebang 139 | 140 | 1.4.0 / 2016-07-11 141 | ================== 142 | 143 | * adding a simple wrapper CLI 144 | * explicitly declare library version in global scope during creating new release to allow using library with custom autoload (composer for example) 145 | 146 | 1.3.0 / 2016-04-05 147 | ================== 148 | 149 | * Introducing libcurl consumer 150 | * Change Consumer to protected instead of private 151 | 152 | 1.2.7 / 2016-03-04 153 | ================== 154 | 155 | * adding global 156 | 157 | 1.2.6 / 2016-03-04 158 | ================== 159 | 160 | * fix version 161 | 162 | 1.2.5 / 2016-03-04 163 | ================== 164 | 165 | * Adding release script, fixing version 166 | * Pass back ->flush() result to allow caller code know if flushed successfully 167 | 168 | 1.2.4 / 2016-02-17 169 | ============= 170 | 171 | * core: fix error name 172 | * send: make send.php executable 173 | * socket: adding fix for FIN packets from remote 174 | 175 | 1.2.3 / 2016-02-01 176 | ================== 177 | 178 | * instead of using just is_int and is_float for checking timestamp, use filter_var since that can detect string ints and floats - if its not a string or float, consider it might be a ISO8601 or some other string, so use strtotime() to support other strings 179 | 180 | 1.2.1 / 2015-12-29 181 | ================== 182 | 183 | * socket open error checking fix 184 | * Fix batch size check before flushing tracking queue 185 | * Fix bug in send.php 186 | 187 | 1.2.0 / 2015-04-27 188 | ================== 189 | 190 | * removing outdated test 191 | * enabling ssl by default 192 | * socket: bump timeout to 5s 193 | 194 | 1.1.3 / 2015-03-03 195 | ================== 196 | 197 | * formatTime: use is_* and fix to support floats 198 | 199 | 200 | 1.1.2 / 2015-03-03 201 | ================== 202 | 203 | * send.php: fix error handling 204 | * client: fix float timestamp handling 205 | 206 | 207 | 1.1.1 / 2015-02-11 208 | ================== 209 | 210 | * Add updated PHP version requirement for @phpunit 211 | * add .sentAt 212 | 213 | 214 | 1.1.0 / 2015-01-07 215 | ================== 216 | 217 | * support microtime 218 | * Update README.md 219 | * drop the io 220 | 221 | 1.0.3 / 2014-10-14 222 | ================== 223 | 224 | * fix: empty array for traits and properties 225 | 226 | 1.0.2 / 2014-09-29 227 | ================== 228 | 229 | * fix: identify(), group() with empty traits 230 | * suppressing logs generated when attempting to write to a reset socket [j0ew00ds] 231 | * Added PHP 5.6, 5.5 and HHVM to travis.yml [Nyholm] 232 | 233 | 1.0.1 / 2014-09-16 234 | ================== 235 | 236 | * fixing validation for Segment::page() calls 237 | * updating send.php error message 238 | * fix send.php to exit gracefully when there is no log file to process 239 | 240 | 1.0.0 / 2014-06-16 241 | ================== 242 | 243 | * update to work with new spec 244 | * add ./composer.phar validation test 245 | * better send.php output 246 | * add validation 247 | * use strtotime in send.php and support php5.3 248 | * rename Analytics to Segment 249 | * add send.php to replace file_reader.py 250 | * add new methods implementation and tests 251 | * implement spec changes 252 | * change tests to reflect spec changes 253 | * test changes: 254 | * Fix typo in composer.json 255 | 256 | 0.4.8 / 8-21-2013 257 | ============= 258 | * adding fix for socket requests which might complete in multiple fwrites 259 | 260 | 0.4.7 / 5-28-2013 261 | ============= 262 | * `chmod` the log file to 0777 so that the file_reader.py can read it 263 | 264 | 0.4.6 / 5-25-2013 265 | ============= 266 | * Check for status existing on response thanks to [@gmoreira](https://github.om/gmoreira) 267 | 268 | 0.4.5 / 5-20-2013 269 | ============= 270 | * Check for empty secret thanks to [@mustela](https://github.com/mustela). 271 | 272 | 0.4.3 / 5-1-2013 273 | ============= 274 | * Make file_reader rename to a file in the same directory as the log file thanks to [@marshally](https://github.com/marshally) 275 | 276 | 0.4.2 / 4-26-2013 277 | ============= 278 | * Fix for $written var on connection error thanks to [@gmoreira](https://github.com/gmoreira) 279 | 280 | 0.4.1 / 4-25-2013 281 | ============= 282 | * Adding fix to file_reader alias 283 | 284 | 0.4.0 / 4-8-2013 285 | ============= 286 | * Full Autoloading an PEAR naming by [Cethy](https://github.com/Cethy) 287 | * Adding alias call 288 | 289 | 0.3.0 / 3-22-2013 290 | ============= 291 | * Adding try-catch around fwrite cal 292 | 293 | 0.2.7 / 3-17-2013 294 | ============= 295 | * Adding file_reader.py fix 296 | 297 | 0.2.6 / 3-15-2013 298 | ============= 299 | * Rename analytics.php -> Analytics.php to allow autoloading by [Cethy](https://github.com/Cethy) 300 | 301 | 0.2.5 / 2-22-2013 302 | ============= 303 | * Trailing whitespace/end php tags fix by [jimrubenstein](https://github.com/jimrubenstein) 304 | 305 | 0.2.4 / 2-19-2013 306 | ============= 307 | * Support fwrite retry on closed socket. 308 | 309 | 0.2.3 / 2-12-2013 310 | ============= 311 | * Adding check for count in properties and traits length. 312 | 313 | 0.2.2 / 2-11-2013 314 | ============= 315 | * Adding default args for properties 316 | 317 | 0.2.1 / 2-1-2013 318 | ============= 319 | * Enabling pfsockopen for persistent connections 320 | * Making socket default 321 | 322 | 0.2.0 / 2-1-2013 323 | ============= 324 | * Updating consumer class to use shared functions. 325 | * Removed *fork* consumer, renamed *fork_queue* to *fork_curl*. 326 | 327 | 0.1.1 / 1-30-2013 328 | ============= 329 | * Adding fork consumer 330 | * Adding fork_queue consumer 331 | * Setting fork_queue consumer to be the default. 332 | 333 | 0.1.0 / 1-29-2013 334 | ============= 335 | 336 | Initial version 337 | -------------------------------------------------------------------------------- /test/AnalyticsTest.php: -------------------------------------------------------------------------------- 1 | true]); 17 | } 18 | 19 | public function testTrack(): void 20 | { 21 | self::assertTrue( 22 | Segment::track( 23 | [ 24 | 'userId' => 'john', 25 | 'event' => 'Module PHP Event', 26 | ] 27 | ) 28 | ); 29 | } 30 | 31 | public function testGroup(): void 32 | { 33 | self::assertTrue( 34 | Segment::group( 35 | [ 36 | 'groupId' => 'group-id', 37 | 'userId' => 'user-id', 38 | 'traits' => [ 39 | 'plan' => 'startup', 40 | ], 41 | ] 42 | ) 43 | ); 44 | } 45 | 46 | public function testGroupAnonymous(): void 47 | { 48 | self::assertTrue( 49 | Segment::group( 50 | [ 51 | 'groupId' => 'group-id', 52 | 'anonymousId' => 'anonymous-id', 53 | 'traits' => [ 54 | 'plan' => 'startup', 55 | ], 56 | ] 57 | ) 58 | ); 59 | } 60 | 61 | public function testGroupNoUser(): void 62 | { 63 | $this->expectExceptionMessage('Segment::group() requires userId or anonymousId'); 64 | $this->expectException(SegmentException::class); 65 | Segment::group( 66 | [ 67 | 'groupId' => 'group-id', 68 | 'traits' => [ 69 | 'plan' => 'startup', 70 | ], 71 | ] 72 | ); 73 | } 74 | 75 | public function testMicrotime(): void 76 | { 77 | self::assertTrue( 78 | Segment::page( 79 | [ 80 | 'anonymousId' => 'anonymous-id', 81 | 'name' => 'analytics-php-microtime', 82 | 'category' => 'docs', 83 | 'timestamp' => microtime(true), 84 | 'properties' => [ 85 | 'path' => '/docs/libraries/php/', 86 | 'url' => 'https://segment.io/docs/libraries/php/', 87 | ], 88 | ] 89 | ) 90 | ); 91 | } 92 | 93 | public function testPage(): void 94 | { 95 | self::assertTrue( 96 | Segment::page( 97 | [ 98 | 'anonymousId' => 'anonymous-id', 99 | 'name' => 'analytics-php', 100 | 'category' => 'docs', 101 | 'properties' => [ 102 | 'path' => '/docs/libraries/php/', 103 | 'url' => 'https://segment.io/docs/libraries/php/', 104 | ], 105 | ] 106 | ) 107 | ); 108 | } 109 | 110 | public function testBasicPage(): void 111 | { 112 | self::assertTrue(Segment::page(['anonymousId' => 'anonymous-id'])); 113 | } 114 | 115 | public function testScreen(): void 116 | { 117 | self::assertTrue( 118 | Segment::screen( 119 | [ 120 | 'anonymousId' => 'anonymous-id', 121 | 'name' => '2048', 122 | 'category' => 'game built with php :)', 123 | 'properties' => [ 124 | 'points' => 300, 125 | ], 126 | ] 127 | ) 128 | ); 129 | } 130 | 131 | public function testBasicScreen(): void 132 | { 133 | self::assertTrue(Segment::screen(['anonymousId' => 'anonymous-id'])); 134 | } 135 | 136 | public function testIdentify(): void 137 | { 138 | self::assertTrue( 139 | Segment::identify( 140 | [ 141 | 'userId' => 'doe', 142 | 'traits' => [ 143 | 'loves_php' => false, 144 | 'birthday' => time(), 145 | ], 146 | ] 147 | ) 148 | ); 149 | } 150 | 151 | public function testEmptyTraits(): void 152 | { 153 | self::assertTrue(Segment::identify(['userId' => 'empty-traits'])); 154 | 155 | self::assertTrue( 156 | Segment::group( 157 | [ 158 | 'userId' => 'empty-traits', 159 | 'groupId' => 'empty-traits', 160 | ] 161 | ) 162 | ); 163 | } 164 | 165 | public function testEmptyArrayTraits(): void 166 | { 167 | self::assertTrue( 168 | Segment::identify( 169 | [ 170 | 'userId' => 'empty-traits', 171 | 'traits' => [], 172 | ] 173 | ) 174 | ); 175 | 176 | self::assertTrue( 177 | Segment::group( 178 | [ 179 | 'userId' => 'empty-traits', 180 | 'groupId' => 'empty-traits', 181 | 'traits' => [], 182 | ] 183 | ) 184 | ); 185 | } 186 | 187 | public function testEmptyProperties(): void 188 | { 189 | self::assertTrue( 190 | Segment::track( 191 | [ 192 | 'userId' => 'user-id', 193 | 'event' => 'empty-properties', 194 | ] 195 | ) 196 | ); 197 | 198 | self::assertTrue( 199 | Segment::page( 200 | [ 201 | 'category' => 'empty-properties', 202 | 'name' => 'empty-properties', 203 | 'userId' => 'user-id', 204 | ] 205 | ) 206 | ); 207 | } 208 | 209 | public function testEmptyArrayProperties(): void 210 | { 211 | self::assertTrue( 212 | Segment::track( 213 | [ 214 | 'userId' => 'user-id', 215 | 'event' => 'empty-properties', 216 | 'properties' => [], 217 | ] 218 | ) 219 | ); 220 | 221 | self::assertTrue( 222 | Segment::page( 223 | [ 224 | 'category' => 'empty-properties', 225 | 'name' => 'empty-properties', 226 | 'userId' => 'user-id', 227 | 'properties' => [], 228 | ] 229 | ) 230 | ); 231 | } 232 | 233 | public function testAlias(): void 234 | { 235 | self::assertTrue( 236 | Segment::alias( 237 | [ 238 | 'previousId' => 'previous-id', 239 | 'userId' => 'user-id', 240 | ] 241 | ) 242 | ); 243 | } 244 | 245 | public function testContextEmpty(): void 246 | { 247 | self::assertTrue( 248 | Segment::track( 249 | [ 250 | 'userId' => 'user-id', 251 | 'event' => 'Context Test', 252 | 'context' => [], 253 | ] 254 | ) 255 | ); 256 | } 257 | 258 | public function testContextCustom(): void 259 | { 260 | self::assertTrue( 261 | Segment::track( 262 | [ 263 | 'userId' => 'user-id', 264 | 'event' => 'Context Test', 265 | 'context' => ['active' => false], 266 | ] 267 | ) 268 | ); 269 | } 270 | 271 | public function testTimestamps(): void 272 | { 273 | self::assertTrue( 274 | Segment::track( 275 | [ 276 | 'userId' => 'user-id', 277 | 'event' => 'integer-timestamp', 278 | 'timestamp' => (int)mktime(0, 0, 0, (int)date('n'), 1, (int)date('Y')), 279 | ] 280 | ) 281 | ); 282 | 283 | self::assertTrue( 284 | Segment::track( 285 | [ 286 | 'userId' => 'user-id', 287 | 'event' => 'string-integer-timestamp', 288 | 'timestamp' => (string)mktime(0, 0, 0, (int)date('n'), 1, (int)date('Y')), 289 | ] 290 | ) 291 | ); 292 | 293 | self::assertTrue( 294 | Segment::track( 295 | [ 296 | 'userId' => 'user-id', 297 | 'event' => 'iso8630-timestamp', 298 | 'timestamp' => date(DATE_ATOM, mktime(0, 0, 0, (int)date('n'), 1, (int)date('Y'))), 299 | ] 300 | ) 301 | ); 302 | 303 | self::assertTrue( 304 | Segment::track( 305 | [ 306 | 'userId' => 'user-id', 307 | 'event' => 'iso8601-timestamp', 308 | 'timestamp' => date(DATE_ATOM, mktime(0, 0, 0, (int)date('n'), 1, (int)date('Y'))), 309 | ] 310 | ) 311 | ); 312 | 313 | self::assertTrue( 314 | Segment::track( 315 | [ 316 | 'userId' => 'user-id', 317 | 'event' => 'strtotime-timestamp', 318 | 'timestamp' => strtotime('1 week ago'), 319 | ] 320 | ) 321 | ); 322 | 323 | self::assertTrue( 324 | Segment::track( 325 | [ 326 | 'userId' => 'user-id', 327 | 'event' => 'microtime-timestamp', 328 | 'timestamp' => microtime(true), 329 | ] 330 | ) 331 | ); 332 | 333 | self::assertTrue( 334 | Segment::track( 335 | [ 336 | 'userId' => 'user-id', 337 | 'event' => 'invalid-float-timestamp', 338 | 'timestamp' => ((string)mktime(0, 0, 0, (int)date('n'), 1, (int)date('Y'))) . '.', 339 | ] 340 | ) 341 | ); 342 | } 343 | } 344 | --------------------------------------------------------------------------------