diff --git a/.docker/php/Dockerfile b/.docker/php/Dockerfile index 600190b..575f036 100644 --- a/.docker/php/Dockerfile +++ b/.docker/php/Dockerfile @@ -1,11 +1,11 @@ FROM php:8.1-cli-alpine AS php-base # Install composer -ENV COMPOSER_ALLOW_SUPERUSER 1 -ENV COMPOSER_HOME /var/www/.composer +ENV COMPOSER_ALLOW_SUPERUSER=1 +ENV COMPOSER_HOME=/var/www/.composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer RUN apk add --update --no-cache --virtual .build-deps linux-headers $PHPIZE_DEPS && \ pecl install xdebug && docker-php-ext-enable xdebug && \ - apk del .build-deps \ + apk del .build-deps diff --git a/.gitattributes b/.gitattributes index f6773ec..af46183 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,7 +4,7 @@ /.gitignore export-ignore /compose.yaml export-ignore /README.md export-ignore -/phpunit.xml.dist export-ignore +/phpunit.xml export-ignore /tests export-ignore /QUICKSTART.md export-ignore /phpstan.neon export-ignore @@ -12,4 +12,6 @@ /phpcs.xml export-ignore /CHANGELOG.md export-ignore /examples.php export-ignore -/qodana.yaml export-ignore \ No newline at end of file +/qodana.yaml export-ignore +/scripts export-ignore +/check-deployed-package export-ignore diff --git a/.github/workflows/check-deployed-package.yml b/.github/workflows/check-deployed-package.yml index ece8c14..17e5b5c 100644 --- a/.github/workflows/check-deployed-package.yml +++ b/.github/workflows/check-deployed-package.yml @@ -1,31 +1,28 @@ name: Check deployed package +# Installs logdash/php-sdk from Packagist and checks that its logs and metrics reach Logdash, see check-deployed-package. +# CI runs it after each release; run it by hand on any branch to check a version, by default the latest. on: + workflow_call: + inputs: + version: + type: string + required: true workflow_dispatch: - workflow_run: - workflows: ["Publish Package to packagist"] - types: - - completed - branches: - - main + inputs: + version: + description: The version to check, such as 1.1.0; empty checks the latest + required: false jobs: check-deployed-package: - name: Check deployed package runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Wait for package to propagate to Packagist - run: sleep 120 - - - name: Make run script executable - run: chmod +x check-deployed-package/run.sh - - - name: Check deployed package + - uses: actions/checkout@v7 + - run: ./check-deployed-package/run.sh env: + VERSION: ${{ inputs.version }} + # Sends the logs and metrics: the ingest key of the SDK smoke tests domain LOGDASH_API_KEY: ${{ secrets.LOGDASH_API_KEY }} - run: ./check-deployed-package/run.sh + # Reads them back: an ldp_ key with logs:read and metrics:read, restricted to that domain + LOGDASH_PERSONAL_API_KEY: ${{ secrets.LOGDASH_PERSONAL_API_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a1a4a2a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # A release in progress must finish; a newer merge waits and releases after it. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + # The oldest PHP that composer.json allows, and the newest + php-version: ["8.1", "8.5"] + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + - run: composer validate --strict + - run: composer install --prefer-dist --no-progress + - run: composer run-script phpstan + - run: composer run-script cs + - run: composer run-script test + - uses: actions/setup-node@v7 + with: + node-version: 24 + - run: node --test 'scripts/*.test.ts' + + # The version the merged commits call for; empty when none of them is a feat, fix, perf, or breaking change. + version: + if: github.event_name == 'push' + runs-on: ubuntu-latest + outputs: + next: ${{ steps.next.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # the tags and commits since the last release + - uses: actions/setup-node@v7 + with: + node-version: 24 + # On its own line, so that version.ts failing fails the job instead of releasing nothing + - id: next + run: | + version=$(node scripts/version.ts) + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Tags the merge v, the tag Packagist publishes as that version. + release: + needs: [test, version] + if: needs.version.outputs.next != '' + runs-on: ubuntu-latest + permissions: + contents: write + env: + V: ${{ needs.version.outputs.next }} + steps: + # Skipped when a rerun after a failed step below finds the release already made + - run: gh release view "v$V" >/dev/null 2>&1 || gh release create "v$V" --generate-notes --target "$GITHUB_SHA" + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + - name: Ask Packagist to read the new tag now + run: | + curl -sS --fail-with-body --retry 3 -X POST -H 'Content-Type: application/json' \ + -d '{"repository":{"url":"https://github.com/logdash-io/php-sdk"}}' \ + "https://packagist.org/api/update-package?username=$PACKAGIST_USERNAME&apiToken=$PACKAGIST_TOKEN" + env: + PACKAGIST_USERNAME: ${{ secrets.PACKAGIST_USERNAME }} + PACKAGIST_TOKEN: ${{ secrets.PACKAGIST_TOKEN }} + + # Installs the version just released from Packagist and checks that its logs and metrics reach Logdash. + check-deployed-package: + needs: [version, release] + uses: ./.github/workflows/check-deployed-package.yml + with: + version: ${{ needs.version.outputs.next }} + secrets: inherit diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..f5d1455 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,20 @@ +name: PR title + +# Squash merges use the PR title as the commit subject, and scripts/version.ts reads it to pick the next version. +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: {} + +jobs: + conventional: + runs-on: ubuntu-latest + steps: + - env: + TITLE: ${{ github.event.pull_request.title }} + run: | + printf '%s' "$TITLE" | grep -qE '^(feat|fix|perf|refactor|docs|test|build|ci|chore|style|revert)(\(.+\))?!?: .+' || { + echo "::error::PR title \"$TITLE\" is not a Conventional Commit, e.g. \"feat(logger): log exceptions with their trace\" or \"fix(metrics): send a mutate once\". feat releases a minor, fix/perf a patch, ! a major; other types do not release." + exit 1 + } diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml deleted file mode 100644 index fb79c72..0000000 --- a/.github/workflows/publish-package.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Publish Package to packagist - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - # php-version: ["8.1", "8.2", "8.3"] - php-version: ["8.1"] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - extensions: json, curl - coverage: xdebug - - - name: Validate composer.json and composer.lock - run: composer validate --strict - - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v4 - with: - path: vendor - key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-php-${{ matrix.php-version }}- - - - name: Install dependencies - run: composer install --prefer-dist --no-progress - - - name: Run PHPStan static analysis - run: composer run-script phpstan - - - name: Run PHP CodeSniffer - run: composer run-script cs - - - name: Run PHPUnit tests - run: composer run-script test - - deploy: - needs: test - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' && github.event_name == 'push' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: "8.1" - extensions: json, curl - - - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-dev --optimize-autoloader - - - name: Create tag from version - id: create_tag - run: | - VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;") - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - if git rev-parse "v$VERSION" >/dev/null 2>&1; then - echo "Tag v$VERSION already exists" - echo "tag_exists=true" >> "$GITHUB_OUTPUT" - else - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git tag "v$VERSION" - git push origin "v$VERSION" - echo "tag_exists=false" >> "$GITHUB_OUTPUT" - fi - - - name: Trigger Packagist update - if: steps.create_tag.outputs.tag_exists == 'false' - run: | - curl -X POST \ - -H "Content-Type: application/json" \ - -d '{"repository":{"url":"https://github.com/logdash-io/php-sdk"}}' \ - "https://packagist.org/api/update-package?username=${{ secrets.PACKAGIST_USERNAME }}&apiToken=${{ secrets.PACKAGIST_TOKEN }}" - - - name: Create GitHub Release - if: steps.create_tag.outputs.tag_exists == 'false' - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: v${{ steps.create_tag.outputs.version }} - release_name: Release v${{ steps.create_tag.outputs.version }} - body: | - ## Changes in v${{ steps.create_tag.outputs.version }} - - See [CHANGELOG.md](CHANGELOG.md) for detailed changes. - draft: false - prerelease: false diff --git a/QUICKSTART.md b/QUICKSTART.md index eddcb3d..58ec9b0 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -129,12 +129,6 @@ Run the examples: php examples.php ``` -Run the test script: - -```bash -php test.php -``` - ## Next Steps 1. Explore the [examples.php](examples.php) file for more usage patterns diff --git a/README.md b/README.md index 24c31e6..e4f1b74 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Packagist Version](https://img.shields.io/packagist/v/logdash/php-sdk)](https://packagist.org/packages/logdash/php-sdk) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![PHP Version](https://img.shields.io/badge/PHP-8.1+-blue.svg)](https://php.net) -[![Tests](https://github.com/logdash-io/php-sdk/workflows/Tests/badge.svg)](https://github.com/logdash-io/php-sdk/actions) +[![CI](https://github.com/logdash-io/php-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/logdash-io/php-sdk/actions/workflows/ci.yml) > **Official PHP SDK for [Logdash.io](https://logdash.io/) - Zero-configuration observability platform designed for developers working on side projects and prototypes.** @@ -161,6 +161,20 @@ Creates a new Logdash instance. - `host` (string, optional): Logdash API host. Defaults to `https://api.logdash.io` - `verbose` (bool, optional): Enable verbose internal logging. Defaults to `false` - `app` (string, optional): Name of this app within its domain, sent with every log, metric and event. See [Apps](#apps) +- `httpClient` (`GuzzleHttp\ClientInterface`, optional): Client that sends the requests to the API, such as one with a proxy. Defaults to a client with a 5 second timeout + +#### `flush(): void` + +Sends the logs, metrics and events buffered so far, and waits for the API. + +Logdash buffers them and sends them together, so that a log costs no HTTP request of its own. +It sends them when 100 are buffered, when the logs near the API's 2 MB request limit, when one comes a second or more after the last send, and when the PHP request or script ends. +Call `flush()` where the process lives on, such as in a queue worker after each job: + +```php +$logdash->logger()->info('Job done'); +$logdash->flush(); +``` ### Logger diff --git a/check-deployed-package/Dockerfile b/check-deployed-package/Dockerfile index 0f563a6..ad3c977 100644 --- a/check-deployed-package/Dockerfile +++ b/check-deployed-package/Dockerfile @@ -1,32 +1,11 @@ -FROM php:8.3-cli +# logdash/php-sdk as Packagist serves it, VERSION or else the latest, never this checkout +FROM composer:2 WORKDIR /app -# Install system dependencies needed for PHP extensions and Composer -RUN apt-get update && apt-get install -y \ - git \ - unzip \ - zip \ - libzip-dev \ - pkg-config \ - && docker-php-ext-install zip \ - && rm -rf /var/lib/apt/lists/* +ARG VERSION= +RUN composer require --no-interaction --no-progress "logdash/php-sdk${VERSION:+:$VERSION}" -# Install Composer -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +COPY check.php . -# Copy the source files (they should be available in the build context) -COPY src /app/logdash-php-sdk/src -COPY composer.json /app/logdash-php-sdk/composer.json - -# Copy test files -COPY check-deployed-package/check.php /app/ - -# Create a simple composer.json that uses the local version and its dependencies -RUN echo '{"require": {"php": ">=8.1", "guzzlehttp/guzzle": "^7.0"}, "autoload": {"psr-4": {"Logdash\\\\": "logdash-php-sdk/src/"}}}' > composer.json - -# Install dependencies and generate autoloader -RUN composer install --no-dev --optimize-autoloader - -# Run the application CMD ["php", "check.php"] diff --git a/check-deployed-package/README.md b/check-deployed-package/README.md index 400bcad..27f230b 100644 --- a/check-deployed-package/README.md +++ b/check-deployed-package/README.md @@ -1,7 +1,12 @@ -# Logdash PHP SDK Demo +# Check the deployed package -Use command below to test if the published SDK works: +`run.sh` installs `logdash/php-sdk` from Packagist in Docker, sends a log of each level and a metric with it, and reads them back from Logdash. +CI runs it after each release, against the version it released. +```sh +LOGDASH_API_KEY= LOGDASH_PERSONAL_API_KEY= VERSION=1.1.0 ./check-deployed-package/run.sh ``` -LOGDASH_API_KEY= ./check-deployed-package/run.sh -``` + +- `LOGDASH_API_KEY` sends: the ingest key of the shared "SDK smoke tests" domain, from its Settings page. The check sends as the app `php-sdk`. +- `LOGDASH_PERSONAL_API_KEY` reads back: an `ldp_` personal API key with `logs:read` and `metrics:read`, restricted to that one domain. +- `VERSION` picks the version, by default the latest. The check waits until Packagist serves it. diff --git a/check-deployed-package/check.php b/check-deployed-package/check.php index ff64e5a..d7edb29 100644 --- a/check-deployed-package/check.php +++ b/check-deployed-package/check.php @@ -2,45 +2,99 @@ declare(strict_types=1); +// Sends logs and metrics with the logdash/php-sdk that the Dockerfile installs, then reads them back from Logdash require_once __DIR__ . '/vendor/autoload.php'; +use GuzzleHttp\Client; use Logdash\Logdash; +function fail(string $message): never +{ + fwrite(STDERR, "Error: {$message}" . PHP_EOL); + exit(1); +} + +// Such as a 401 from whoami for a personal API key that is wrong, or revoked +set_exception_handler(fn (Throwable $e) => fail($e->getMessage())); + +$apiKey = getenv('LOGDASH_API_KEY') ?: fail('Set LOGDASH_API_KEY to the ingest key of the SDK smoke tests domain'); +$personalApiKey = getenv('LOGDASH_PERSONAL_API_KEY') ?: fail( + 'Set LOGDASH_PERSONAL_API_KEY to an ldp_ key with logs:read and metrics:read, restricted to the SDK smoke tests domain' +); + +// Each SDK sends as its own app on the shared smoke-test domain +$app = 'php-sdk'; +$seed = bin2hex(random_bytes(8)); +$users = random_int(1, 1_000_000); + echo "=== Logdash PHP SDK package check ===" . PHP_EOL; +echo "Sending a log of each level with seed {$seed}, setting users to {$users} and adding 1" . PHP_EOL; + +$logdash = Logdash::create(['apiKey' => $apiKey, 'verbose' => true, 'app' => $app]); +$sent = []; + +foreach ( + [ + 'info' => 'info', + 'error' => 'error', + 'warn' => 'warning', + 'debug' => 'debug', + 'http' => 'http', + 'silly' => 'silly', + 'verbose' => 'verbose', + ] as $method => $level +) { + $message = "This is the {$level} log {$seed}"; + $logdash->logger()->{$method}($message); + $sent[$message] = $level; +} + +$logdash->metrics()->set('users', $users); +// Buffered together, a set and a mutate merge into one set, and the API would never get a change +$logdash->flush(); +$logdash->metrics()->mutate('users', 1); +// The end of this script would send them too, but they must be there to read back +$logdash->flush(); + +$api = new Client([ + 'base_uri' => 'https://api.logdash.io', + 'headers' => ['Authorization' => "Bearer {$personalApiKey}"], + 'timeout' => 10, +]); +$get = fn (string $path, array $query = []): array => json_decode( + (string) $api->get($path, ['query' => $query])->getBody(), + true, + 512, + JSON_THROW_ON_ERROR +); + +// The key reads only the smoke-test domain, which whoami names +$access = $get('/personal-api-keys/whoami')['access']; + +if (($access['kind'] ?? null) !== 'clusters' || count($access['ids'] ?? []) !== 1) { + fail('LOGDASH_PERSONAL_API_KEY must be restricted to the smoke-test domain, its access is ' . json_encode($access)); +} + +$domainId = $access['ids'][0]; +echo "Reading them back from app {$app} on domain {$domainId}" . PHP_EOL; + +// The API stores what it gets every second +for ($attempt = 1; $attempt <= 30; $attempt++) { + sleep(2); + $logs = $get("/clusters/{$domainId}/logs", ['apps' => $app, 'limit' => 100, 'searchString' => $seed]); + $found = array_column($logs, 'level', 'message'); + $metrics = array_filter($get("/clusters/{$domainId}/metrics"), fn (array $m) => $m['app'] === $app); + $value = array_column($metrics, 'value', 'name')['users'] ?? null; + + if ($found == $sent && $value == $users + 1) { + echo "Found every log with its level, and users at {$value}" . PHP_EOL; + exit(0); + } +} + +$missing = array_keys(array_diff_assoc($sent, $found)); -$apiKey = getenv('LOGDASH_API_KEY') ?: null; -$logsSeed = getenv('LOGS_SEED') ?: 'default'; -$metricsSeed = getenv('METRICS_SEED') ?: '1'; - -echo "Using API Key: " . ($apiKey ? '[REDACTED]' : 'none') . PHP_EOL; -echo "Using Logs Seed: {$logsSeed}" . PHP_EOL; -echo "Using Metrics Seed: {$metricsSeed}" . PHP_EOL; - -// Create Logdash instance -$logdash = Logdash::create($apiKey ? [ - 'apiKey' => $apiKey, - 'verbose' => true -] : []); - -$logger = $logdash->logger(); -$metrics = $logdash->metrics(); - -// Log messages with seed appended -$logger->info("This is an info log {$logsSeed}"); -$logger->error("This is an error log {$logsSeed}"); -$logger->warn("This is a warning log {$logsSeed}"); -$logger->debug("This is a debug log {$logsSeed}"); -$logger->http("This is a http log {$logsSeed}"); -$logger->silly("This is a silly log {$logsSeed}"); -$logger->verbose("This is a verbose log {$logsSeed}"); - -// Set and mutate metrics with seed -$metricsSeedValue = (float) $metricsSeed; -$metrics->set('users', $metricsSeedValue); -$metrics->mutate('users', 1.0); - -// Wait to ensure data is sent -echo "Waiting 5 seconds to ensure data is sent..." . PHP_EOL; -sleep(5); - -echo "Demo completed!" . PHP_EOL; +fail('After a minute, Logdash ' . implode(' and ', array_filter([ + $missing === [] ? '' : 'lacks these logs at their level: ' . json_encode($missing), + $value == $users + 1 ? '' : 'has users at ' . var_export($value, true) . ' instead of ' . ($users + 1), +]))); diff --git a/check-deployed-package/composer.json b/check-deployed-package/composer.json deleted file mode 100644 index 0e83213..0000000 --- a/check-deployed-package/composer.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "logdash/package-test", - "description": "Test application for Logdash PHP SDK deployed package", - "type": "project", - "require": { - "php": ">=8.1", - "logdash/php-sdk": "^1.0.3" - }, - "autoload": { - "psr-4": { - "LogdashTest\\": "src/" - } - } -} diff --git a/check-deployed-package/run.sh b/check-deployed-package/run.sh index b18a4f1..4c60fcd 100755 --- a/check-deployed-package/run.sh +++ b/check-deployed-package/run.sh @@ -1,124 +1,21 @@ #!/bin/sh -set -e - -# Generate random 5-character seed -LOGS_SEED=$(openssl rand -hex 2 | cut -c1-5) -echo "Generated seed: $LOGS_SEED" - -# Generate random metrics seed (1-1,000,000) -METRICS_SEED=$(awk 'BEGIN{srand(); print int(rand()*1000000)+1}') -echo "Generated metrics seed: $METRICS_SEED" - -echo "Building Logdash demo Docker image (using published package)..." -docker build --no-cache -t logdash-php-demo -f check-deployed-package/Dockerfile . - -echo -echo "Running Logdash demo..." -echo - -# Run in non-interactive mode which works everywhere -docker run --rm \ - -e LOGDASH_API_KEY="${LOGDASH_API_KEY}" \ - -e LOGS_SEED="${LOGS_SEED}" \ - -e METRICS_SEED="${METRICS_SEED}" \ - logdash-php-demo - -echo -echo "Demo completed!" - -echo -echo "Authenticating with Logdash API..." - -# Authenticate with API using the API key -AUTH_RESPONSE=$(curl -s -X 'POST' \ - 'https://api.logdash.io/auth/api-key' \ - -H 'accept: application/json' \ - -H 'Content-Type: application/json' \ - -d "{ - \"apiKey\": \"${LOGDASH_API_KEY}\" -}") - -# Extract token and projectId from response -TOKEN=$(echo "$AUTH_RESPONSE" | grep -o '"token":"[^"]*"' | sed 's/"token":"\(.*\)"/\1/') -PROJECT_ID=$(echo "$AUTH_RESPONSE" | grep -o '"projectId":"[^"]*"' | sed 's/"projectId":"\(.*\)"/\1/') - -if [ -z "$TOKEN" ] || [ -z "$PROJECT_ID" ]; then - echo "Error: Failed to authenticate with Logdash API" - echo "Response: $AUTH_RESPONSE" - exit 1 -fi - -echo "Authentication successful. Project ID: $PROJECT_ID" - -echo -echo "Fetching logs from Logdash API..." - -# Fetch logs from the API -LOGS_RESPONSE=$(curl -s -X 'GET' \ - "https://api.logdash.io/projects/${PROJECT_ID}/logs?limit=10" \ - -H 'accept: application/json' \ - -H "Authorization: Bearer ${TOKEN}") - -echo "Logs fetched successfully" - -echo -echo "Validating log messages..." - -# Expected log messages with seed -EXPECTED_MESSAGES="This is an info log ${LOGS_SEED} -This is an error log ${LOGS_SEED} -This is a warning log ${LOGS_SEED} -This is a debug log ${LOGS_SEED} -This is a http log ${LOGS_SEED} -This is a silly log ${LOGS_SEED} -This is an info log ${LOGS_SEED} -This is a verbose log ${LOGS_SEED}" - -# Check if all expected messages are present in the logs -echo "$EXPECTED_MESSAGES" | while IFS= read -r expected_msg; do - if ! echo "$LOGS_RESPONSE" | grep -q "$expected_msg"; then - echo "Error: Expected log message not found: '$expected_msg'" - echo "Logs response: $LOGS_RESPONSE" - exit 1 +# Installs logdash/php-sdk from Packagist in Docker and checks that its logs and metrics reach Logdash, see README.md +set -eu +cd "$(dirname "$0")" + +if [ -n "${VERSION:-}" ]; then + echo "Waiting for Packagist to serve logdash/php-sdk $VERSION..." + tries=0 + # Packagist lists the tag v1.1.0 as version "v1.1.0" + until curl -fsS https://repo.packagist.org/p2/logdash/php-sdk.json | grep -F "\"version\":\"v$VERSION\"" > /dev/null; do + tries=$((tries + 1)) + if [ "$tries" -ge 40 ]; then + echo "Packagist does not serve logdash/php-sdk $VERSION after 10 minutes" >&2 + exit 1 fi - echo "✓ Found: '$expected_msg'" -done - -echo -echo "Fetching metrics from Logdash API..." - -# Fetch metrics from the API -METRICS_RESPONSE=$(curl -s -X 'GET' \ - "https://api.logdash.io/projects/${PROJECT_ID}/metrics" \ - -H 'accept: application/json' \ - -H "Authorization: Bearer ${TOKEN}") - -echo "Metrics fetched successfully" - -echo -echo "Validating metrics..." - -# Expected users metric value (metrics_seed + 1) -EXPECTED_USERS_VALUE=$((METRICS_SEED + 1)) - -# Check if users metric exists with correct value -if ! echo "$METRICS_RESPONSE" | grep -q '"name":"users"'; then - echo "Error: Users metric not found" - echo "Metrics response: $METRICS_RESPONSE" - exit 1 + sleep 15 + done fi -# Extract the value of the users metric and check if it matches expected value -USERS_VALUE=$(echo "$METRICS_RESPONSE" | sed 's/},{/}\n{/g' | grep '"name":"users"' | grep -o '"value":[0-9]*' | sed 's/"value"://') - -if [ "$USERS_VALUE" != "$EXPECTED_USERS_VALUE" ]; then - echo "Error: Users metric value mismatch. Expected: $EXPECTED_USERS_VALUE, Found: $USERS_VALUE" - echo "Metrics response: $METRICS_RESPONSE" - exit 1 -fi - -echo "✓ Found users metric with correct value: $USERS_VALUE" - -echo -echo "All expected log messages and metrics found successfully!" -echo "Validation completed!" +docker build --no-cache --build-arg VERSION="${VERSION:-}" -t logdash-php-sdk-check . +docker run --rm -e LOGDASH_API_KEY -e LOGDASH_PERSONAL_API_KEY logdash-php-sdk-check diff --git a/scripts/version.test.ts b/scripts/version.test.ts new file mode 100644 index 0000000..316cade --- /dev/null +++ b/scripts/version.test.ts @@ -0,0 +1,16 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { nextVersion } from './version.ts' + +test('the next version follows the Conventional Commits since the last release', () => { + assert.equal(nextVersion(null, []), '0.1.0', 'first release') + assert.equal(nextVersion('0.1.0', []), null, 'nothing merged') + assert.equal(nextVersion('0.1.0', ['chore: deps', 'ci(release): cache', 'docs: readme']), null, 'nothing users get') + assert.equal(nextVersion('0.1.3', ['fix: crash on #', 'chore: x']), '0.1.4') + assert.equal(nextVersion('0.1.3', ['perf(export): faster']), '0.1.4') + assert.equal(nextVersion('0.1.3', ['fix: a', 'feat(timeline): b']), '0.2.0') + assert.equal(nextVersion('0.9.3', ['feat!: new format']), '1.0.0') + assert.equal(nextVersion('0.9.3', ['refactor(projects): x\n\nBREAKING CHANGE: old bundles no longer open']), '1.0.0') + assert.equal(nextVersion('1.2.3', ['Feature: no prefix', 'fixed: typo', 'feat : spaced', 'featx: y']), null, 'only exact types count') + assert.equal(nextVersion('1.2.3', ['chore: x\n\n* feat: from a squashed commit list']), null, 'only subjects count') +}) diff --git a/scripts/version.ts b/scripts/version.ts new file mode 100644 index 0000000..000c0f5 --- /dev/null +++ b/scripts/version.ts @@ -0,0 +1,26 @@ +// `node scripts/version.ts`: the version the commits since the last release call for, from their +// Conventional Commit subjects, or nothing when none of them changes the app. CI runs it after each +// merge to main and releases what it prints. Releases are tagged v, the tag Packagist +// reads the version from; composer.json carries none. +import { execFileSync } from 'node:child_process' + +/** The next version after `last` (null before the first release), given the commit messages since it. */ +export function nextVersion(last: string | null, messages: string[]): string | null { + if (!last) return '0.1.0' + const subjects = messages.map((m) => m.split('\n')[0]) + const [major, minor, patch] = last.split('.').map(Number) + if (subjects.some((s) => /^\w+(\(.+\))?!:/.test(s)) || messages.some((m) => /^BREAKING[ -]CHANGE:/m.test(m))) return `${major + 1}.0.0` + if (subjects.some((s) => /^feat(\(.+\))?:/.test(s))) return `${major}.${minor + 1}.0` + if (subjects.some((s) => /^(fix|perf)(\(.+\))?:/.test(s))) return `${major}.${minor}.${patch + 1}` + return null +} + +if (import.meta.main) { + const git = (...args: string[]) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) + let tag: string | null = null + try { + tag = git('describe', '--tags', '--abbrev=0', '--match', 'v[0-9]*').trim() + } catch {} // no release yet + const messages = tag ? git('log', '--format=%B%x00', `${tag}..HEAD`).split('\0').map((m) => m.trim()).filter(Boolean) : [] + console.log(nextVersion(tag?.slice(1) ?? null, messages) ?? '') +} diff --git a/src/Events/Events.php b/src/Events/Events.php index ab9d4a2..54afe8f 100644 --- a/src/Events/Events.php +++ b/src/Events/Events.php @@ -4,9 +4,8 @@ namespace Logdash\Events; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; use Logdash\Logger\InternalLogger; +use Logdash\Queue\RequestQueue; use Logdash\Types\RequiredInitializationParams; class Events @@ -15,12 +14,13 @@ class Events // Belong to the website tracker, so the API rejects them private const WEBSITE_EVENTS = ['pageview', 'pageleave', 'browser_error']; - private Client $httpClient; + private RequestQueue $queue; public function __construct( - private readonly RequiredInitializationParams $params + private readonly RequiredInitializationParams $params, + ?RequestQueue $queue = null ) { - $this->httpClient = new Client(); + $this->queue = $queue ?? new RequestQueue($params); } /** @@ -63,22 +63,8 @@ public function track(string $name, array $props, string|int|null $userId): void InternalLogger::getInternalLogger()->verbose("Tracking event {$name}"); } - try { - $this->httpClient->post($this->params->host . '/events', [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'project-api-key' => $this->params->apiKey, - ], - // The API stores an event once per id, so a retry must send the same one - 'json' => ['id' => self::uuidV4()] + $event, - 'timeout' => 5, - ]); - } catch (GuzzleException $e) { - if ($this->params->verbose) { - InternalLogger::getInternalLogger()->verbose("Failed to send event: " . $e->getMessage()); - } - // Fail silently in production - } + // The API stores an event once per id, so a retry must send the same one + $this->queue->event(['id' => self::uuidV4()] + $event); } private static function uuidV4(): string diff --git a/src/Logdash.php b/src/Logdash.php index 6fe5821..24a507c 100644 --- a/src/Logdash.php +++ b/src/Logdash.php @@ -4,9 +4,11 @@ namespace Logdash; +use GuzzleHttp\ClientInterface; use Logdash\Events\Events; use Logdash\Logger\Logger; use Logdash\Metrics\BaseMetrics; +use Logdash\Queue\RequestQueue; use Logdash\Types\InitializationParams; use Logdash\Types\LogLevel; @@ -18,19 +20,23 @@ class Logdash private Logger $logger; private BaseMetrics $metrics; private Events $events; + private RequestQueue $queue; private function __construct( Logger $logger, BaseMetrics $metrics, - Events $events + Events $events, + RequestQueue $queue ) { $this->logger = $logger; $this->metrics = $metrics; $this->events = $events; + $this->queue = $queue; } /** - * @param array{apiKey?:string, host?:string, verbose?:bool, app?:string}|null $params + * @param array{apiKey?:string, host?:string, verbose?:bool, app?:string, httpClient?:ClientInterface}|null $params + * httpClient sends the requests to the API, such as one with a proxy or a mock in tests */ public static function create(?array $params = null): self { @@ -42,8 +48,10 @@ public static function create(?array $params = null): self ); $requiredParams = $initParams->toRequired(); - $logSync = createLogSync($requiredParams); - $metrics = createMetrics($requiredParams); + // One queue sends the logs, metrics and events of this instance together + $queue = new RequestQueue($requiredParams, $params['httpClient'] ?? null); + $logSync = createLogSync($requiredParams, $queue); + $metrics = createMetrics($requiredParams, $queue); $logger = new Logger( logMethod: null, @@ -53,7 +61,7 @@ public static function create(?array $params = null): self } ); - return new self($logger, $metrics, new Events($requiredParams)); + return new self($logger, $metrics, new Events($requiredParams, $queue), $queue); } public function logger(): Logger @@ -77,4 +85,13 @@ public function track(string $name, array $props = [], string|int|null $userId = { $this->events->track($name, $props, $userId); } + + /** + * Sends the logs, metrics and events buffered so far and waits for the API, never throws. + * A PHP request or script sends them when it ends, so call this where the process lives on, such as after a job. + */ + public function flush(): void + { + $this->queue->flush(); + } } diff --git a/src/Metrics/CreateMetrics.php b/src/Metrics/CreateMetrics.php index 0b64a2d..faa1d82 100644 --- a/src/Metrics/CreateMetrics.php +++ b/src/Metrics/CreateMetrics.php @@ -4,13 +4,14 @@ namespace Logdash\Metrics; +use Logdash\Queue\RequestQueue; use Logdash\Types\RequiredInitializationParams; -function createMetrics(RequiredInitializationParams $params): BaseMetrics +function createMetrics(RequiredInitializationParams $params, ?RequestQueue $queue = null): BaseMetrics { if (empty($params->apiKey)) { return new NoopMetrics(); } - return new Metrics($params); + return new Metrics($params, $queue); } diff --git a/src/Metrics/Metrics.php b/src/Metrics/Metrics.php index 22bd087..9f6e755 100644 --- a/src/Metrics/Metrics.php +++ b/src/Metrics/Metrics.php @@ -4,19 +4,19 @@ namespace Logdash\Metrics; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; use Logdash\Logger\InternalLogger; +use Logdash\Queue\RequestQueue; use Logdash\Types\RequiredInitializationParams; class Metrics implements BaseMetrics { - private Client $httpClient; + private RequestQueue $queue; public function __construct( - private readonly RequiredInitializationParams $params + private readonly RequiredInitializationParams $params, + ?RequestQueue $queue = null ) { - $this->httpClient = new Client(); + $this->queue = $queue ?? new RequestQueue($params); } public function set(string $name, float $value): void @@ -25,7 +25,7 @@ public function set(string $name, float $value): void InternalLogger::getInternalLogger()->verbose("Setting metric {$name} to {$value}"); } - $this->sendMetric($name, $value, MetricOperation::SET); + $this->enqueue($name, $value, MetricOperation::SET); } public function mutate(string $name, float $value): void @@ -34,30 +34,14 @@ public function mutate(string $name, float $value): void InternalLogger::getInternalLogger()->verbose("Mutating metric {$name} by {$value}"); } - $this->sendMetric($name, $value, MetricOperation::CHANGE); + $this->enqueue($name, $value, MetricOperation::CHANGE); } - private function sendMetric(string $name, float $value, MetricOperation $operation): void + private function enqueue(string $name, float $value, MetricOperation $operation): void { - try { - $this->httpClient->put($this->params->host . '/metrics', [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'project-api-key' => $this->params->apiKey, - ], - 'json' => [ - 'name' => $name, - 'value' => $value, - 'operation' => $operation->value, - ...($this->params->app === null ? [] : ['app' => $this->params->app]), - ], - 'timeout' => 5, - ]); - } catch (GuzzleException $e) { - if ($this->params->verbose) { - InternalLogger::getInternalLogger()->verbose("Failed to send metric: " . $e->getMessage()); - } - // Fail silently in production - } + $this->queue->metric( + ['name' => $name, 'value' => $value, 'operation' => $operation->value] + + ($this->params->app === null ? [] : ['app' => $this->params->app]) + ); } } diff --git a/src/Queue/RequestQueue.php b/src/Queue/RequestQueue.php new file mode 100644 index 0000000..be8541d --- /dev/null +++ b/src/Queue/RequestQueue.php @@ -0,0 +1,217 @@ +> */ + private array $logs = []; + /** @var array By metric name */ + private array $metrics = []; + /** @var list> */ + private array $events = []; + private int $logBytes = 0; + private int $lastFlushAt; + private bool $flushesAtShutdown = false; + private int $pid; + private ClientInterface $httpClient; + + public function __construct( + private readonly RequiredInitializationParams $params, + ?ClientInterface $httpClient = null + ) { + $this->httpClient = $httpClient ?? new Client([ + // An unreachable API fails in 2 seconds instead of the whole timeout + 'connect_timeout' => 2, + 'timeout' => 5, + ]); + $this->lastFlushAt = hrtime(true); + $this->pid = (int) getmypid(); + } + + /** + * @param array $log + */ + public function log(array $log): void + { + $this->dropIfForked(); + $bytes = strlen((string) json_encode($log, self::JSON_FLAGS)) + 1; + + if ($this->logs !== [] && $this->logBytes + $bytes > self::MAX_LOG_BYTES) { + $this->flush(); + } + + $this->logs[] = $log; + $this->logBytes += $bytes; + $this->buffered(); + } + + /** + * @param array{name: string, value: float, operation: string, app?: string} $metric + */ + public function metric(array $metric): void + { + $this->dropIfForked(); + // Merged the way the API merges the updates it buffers: a set replaces the update, a change adds to it. + // As one request, the updates of a metric cannot arrive out of order. + $update = $this->metrics[$metric['name']] ?? null; + $this->metrics[$metric['name']] = $update === null || $metric['operation'] === MetricOperation::SET->value + ? $metric + : ['value' => $update['value'] + $metric['value']] + $update; + $this->buffered(); + } + + /** + * @param array $event + */ + public function event(array $event): void + { + $this->dropIfForked(); + $this->events[] = $event; + $this->buffered(); + } + + /** + * Sends everything buffered and waits for the API, never throws + */ + public function flush(): void + { + $this->dropIfForked(); + // Logs and events that get a 5xx get one retry: the API stores an event once per id. + // A timeout gets none: the API may have stored the batch, and a retry would double the wait. + // Metrics get none: a change the API applied before the connection broke would apply twice. + $requests = [ + ...($this->logs === [] ? [] : [['POST', '/logs/batch', ['logs' => $this->logs], true]]), + ...array_map( + fn (array $metric): array => ['PUT', '/metrics', $metric, false], + array_values($this->metrics) + ), + ...array_map(fn (array $event): array => ['POST', '/events', $event, true], $this->events), + ]; + $this->logs = $this->metrics = $this->events = []; + $this->logBytes = 0; + $this->lastFlushAt = hrtime(true); + + $retries = array_filter($this->send($requests), fn (array $request): bool => $request[3]); + + if ($retries !== []) { + usleep(self::RETRY_DELAY_MICROSECONDS); + $this->send($retries); + } + } + + private function buffered(): void + { + if (!$this->flushesAtShutdown) { + $this->flushesAtShutdown = true; + register_shutdown_function(function (): void { + // Reset first: a shutdown function after this one that logs registers this again, to run after it + $this->flushesAtShutdown = false; + $this->flush(); + }); + } + + if ( + count($this->logs) + count($this->metrics) + count($this->events) >= self::MAX_BUFFERED + || hrtime(true) - $this->lastFlushAt >= self::MAX_AGE_NANOSECONDS + ) { + $this->flush(); + } + } + + /** + * A child of pcntl_fork() starts with its parent's buffer, which the parent sends itself + */ + private function dropIfForked(): void + { + if ($this->pid !== getmypid()) { + $this->pid = (int) getmypid(); + $this->logs = $this->metrics = $this->events = []; + $this->logBytes = 0; + } + } + + /** + * Sends $requests at once + * + * @param array, bool}> $requests The method, path and body + * of each, and whether it gets a retry + * @return array, bool}> Those that failed for a reason that + * can pass: a 5xx + */ + private function send(array $requests): array + { + $sends = []; + + foreach ($requests as $index => [$method, $path, $body]) { + // Bad UTF-8 becomes U+FFFD, so that one log does not cost its whole batch + $json = json_encode($body, self::JSON_FLAGS); + + if ($json === false) { + // A metric or event prop of INF or NAN, which JSON has no number for + $this->verbose("Failed to send {$method} {$path}: " . json_last_error_msg()); + continue; + } + + $sends[$index] = fn () => $this->httpClient->requestAsync($method, $this->params->host . $path, [ + 'body' => $json, + 'headers' => ['Content-Type' => 'application/json', 'project-api-key' => $this->params->apiKey], + ]); + } + + $failed = []; + + try { + (new Pool($this->httpClient, $sends, [ + 'concurrency' => self::CONCURRENCY, + 'rejected' => function (Throwable $reason, int $index) use ($requests, &$failed): void { + $this->verbose('Failed to send: ' . $reason->getMessage()); + + // A 4xx, such as a rate limit, would answer a retry the same + if ($reason instanceof ServerException) { + $failed[$index] = $requests[$index]; + } + }, + ]))->promise()->wait(); + } catch (Throwable $e) { + // Such as a host that is no URL + $this->verbose('Failed to send: ' . $e->getMessage()); + } + + return $failed; + } + + private function verbose(string $message): void + { + if ($this->params->verbose) { + InternalLogger::getInternalLogger()->verbose($message); + } + } +} diff --git a/src/Sync/CreateLogSync.php b/src/Sync/CreateLogSync.php index f6aeaab..f69edd2 100644 --- a/src/Sync/CreateLogSync.php +++ b/src/Sync/CreateLogSync.php @@ -4,9 +4,10 @@ namespace Logdash\Sync; +use Logdash\Queue\RequestQueue; use Logdash\Types\RequiredInitializationParams; -function createLogSync(RequiredInitializationParams $params): LogSync +function createLogSync(RequiredInitializationParams $params, ?RequestQueue $queue = null): LogSync { if (empty($params->apiKey)) { \Logdash\Logger\InternalLogger::getInternalLogger()->log( @@ -16,5 +17,5 @@ function createLogSync(RequiredInitializationParams $params): LogSync return new NoopLogSync(); } - return new HttpLogSync($params); + return new HttpLogSync($params, $queue); } diff --git a/src/Sync/HttpLogSync.php b/src/Sync/HttpLogSync.php index 507220e..db98bfc 100644 --- a/src/Sync/HttpLogSync.php +++ b/src/Sync/HttpLogSync.php @@ -4,51 +4,56 @@ namespace Logdash\Sync; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\GuzzleException; -use Logdash\Logger\InternalLogger; +use Logdash\Queue\RequestQueue; use Logdash\Types\LogLevel; use Logdash\Types\RequiredInitializationParams; class HttpLogSync implements LogSync { + // The API keeps the first 4096 characters of a message + private const MAX_MESSAGE_CHARACTERS = 4096; + private int $sequenceNumber = 0; - private Client $httpClient; + private RequestQueue $queue; public function __construct( - private readonly RequiredInitializationParams $params + private readonly RequiredInitializationParams $params, + ?RequestQueue $queue = null ) { - $this->httpClient = new Client(); + $this->queue = $queue ?? new RequestQueue($params); + } + + public function send(string $message, LogLevel $level, string $createdAt): void + { + $this->queue->log([ + 'message' => self::cut($message), + 'level' => $level->value, + 'createdAt' => $createdAt, + 'sequenceNumber' => $this->sequenceNumber++, + ...($this->params->app === null ? [] : ['app' => $this->params->app]), + ]); } /** - * TODO: - * - queue - * - retry - * - batching + * The first 4096 characters of $message, without mbstring, which PHP may lack */ - public function send(string $message, LogLevel $level, string $createdAt): void + private static function cut(string $message): string { - try { - $this->httpClient->post($this->params->host . '/logs', [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'project-api-key' => $this->params->apiKey, - ], - 'json' => [ - 'message' => $message, - 'level' => $level->value, - 'createdAt' => $createdAt, - 'sequenceNumber' => $this->sequenceNumber++, - ...($this->params->app === null ? [] : ['app' => $this->params->app]), - ], - 'timeout' => 5, - ]); - } catch (GuzzleException $e) { - if ($this->params->verbose) { - InternalLogger::getInternalLogger()->verbose("Failed to send log: " . $e->getMessage()); - } - // Fail silently in production + if (strlen($message) <= self::MAX_MESSAGE_CHARACTERS) { + return $message; } + + // A character is a byte and the UTF-8 continuation bytes after it, so bad UTF-8 stays as it is for + // json_encode to replace + $length = strlen($message); + $cut = 0; + + for ($characters = 0; $characters < self::MAX_MESSAGE_CHARACTERS && $cut < $length; $characters++) { + do { + $cut++; + } while ($cut < $length && (ord($message[$cut]) & 0xC0) === 0x80); + } + + return substr($message, 0, $cut); } } diff --git a/tests/LogdashTest.php b/tests/LogdashTest.php index bfdc66e..dfb16e1 100644 --- a/tests/LogdashTest.php +++ b/tests/LogdashTest.php @@ -4,6 +4,11 @@ namespace Logdash\Tests; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Response; use Logdash\Logdash; use Logdash\Types\LogLevel; use PHPUnit\Framework\Attributes\DataProvider; @@ -17,11 +22,13 @@ class LogdashTest extends TestCase private static $server; private static string $host; private static string $requestsFile; + private static string $statusesFile; public static function setUpBeforeClass(): void { // A local server that records every request the SDK sends to it, see tests/server.php self::$requestsFile = tempnam(sys_get_temp_dir(), 'logdash'); + self::$statusesFile = tempnam(sys_get_temp_dir(), 'logdash'); $socket = stream_socket_server('tcp://127.0.0.1:0'); $address = stream_socket_get_name($socket, false); fclose($socket); @@ -32,7 +39,7 @@ public static function setUpBeforeClass(): void [['null'], ['null'], ['null']], $pipes, null, - ['LOGDASH_REQUESTS_FILE' => self::$requestsFile] + getenv() + ['LOGDASH_REQUESTS_FILE' => self::$requestsFile, 'LOGDASH_STATUSES_FILE' => self::$statusesFile] + getenv() ); for ($attempt = 0; $attempt < 100 && @stream_socket_client("tcp://{$address}") === false; $attempt++) { @@ -45,11 +52,13 @@ public static function tearDownAfterClass(): void proc_terminate(self::$server); proc_close(self::$server); unlink(self::$requestsFile); + unlink(self::$statusesFile); } protected function setUp(): void { file_put_contents(self::$requestsFile, ''); + file_put_contents(self::$statusesFile, ''); } public function testCreateWithoutApiKey(): void @@ -104,15 +113,18 @@ public function testMetricsMethods(): void $this->assertTrue(true); // If we get here, no exceptions were thrown } - public function testLoggingInvalidUtf8InAnArrayDoesNotThrow(): void + public function testInvalidUtf8NeitherThrowsNorCostsTheBatch(): void { $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); $logdash->logger()->info('upload', ['file' => "caf\xe9.txt"]); + $logdash->logger()->info("caf\xe9.txt"); + $logdash->logger()->info('done'); + $logdash->flush(); $this->assertSame( - ['upload {"file":"caf\\ufffd.txt"}'], - array_column($this->receivedBodies('POST /logs'), 'message') + ['upload {"file":"caf\\ufffd.txt"}', "caf\u{fffd}.txt", 'done'], + array_column($this->sentLogs(), 'message') ); } @@ -127,6 +139,245 @@ public function testLogLevelEnum(): void $this->assertEquals('silly', LogLevel::SILLY->value); } + public function testNothingIsSentBeforeAFlush(): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + + $logdash->logger()->info('Test info'); + $logdash->metrics()->set('test_metric', 42.5); + $logdash->track('agent_run'); + + $this->assertSame('', file_get_contents(self::$requestsFile)); + + $logdash->flush(); + + $this->assertCount(1, $this->sentLogs()); + $this->assertCount(1, $this->receivedRequests('PUT /metrics')); + $this->assertCount(1, $this->receivedRequests('POST /events')); + } + + public function testLogsGoInOneBatchInTheirOrder(): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + + $logdash->logger()->info('first'); + $logdash->logger()->error('second'); + $logdash->logger()->warn('third'); + $logdash->flush(); + + $batches = $this->receivedRequests('POST /logs/batch'); + + $this->assertCount(1, $batches); + $this->assertSame('test-api-key', $batches[0]['headers']['project-api-key']); + $this->assertSame('application/json', $batches[0]['headers']['content-type']); + $this->assertSame( + [['first', 'info', 0], ['second', 'error', 1], ['third', 'warning', 2]], + array_map( + fn (array $log): array => [$log['message'], $log['level'], $log['sequenceNumber']], + $batches[0]['body']['logs'] + ) + ); + } + + public function testAScriptSendsWhatItBufferedWhenItEnds(): void + { + $this->printedBy(sprintf( + '$logdash = Logdash\Logdash::create(%s); + for ($i = 0; $i <= 100; $i++) { + $logdash->logger()->info("log {$i}"); + } + $logdash->metrics()->mutate("test_counter", 1); + $logdash->track("agent_run");', + var_export(['apiKey' => 'test-api-key', 'host' => self::$host], true) + )); + + // The first 100 fill a batch and go at once, the rest when the script ends + $this->assertSame( + [100, 1], + array_map(fn (array $body): int => count($body['logs']), $this->receivedBodies('POST /logs/batch')) + ); + $this->assertCount(1, $this->receivedRequests('PUT /metrics')); + $this->assertCount(1, $this->receivedRequests('POST /events')); + } + + public function testALogFromALaterShutdownFunctionIsSentToo(): void + { + $this->printedBy(sprintf( + '$logger = Logdash\Logdash::create(%s)->logger(); + $logger->info("before"); + register_shutdown_function(fn () => $logger->error("from a shutdown function"));', + var_export(['apiKey' => 'test-api-key', 'host' => self::$host], true) + )); + + $this->assertSame(['before', 'from a shutdown function'], array_column($this->sentLogs(), 'message')); + } + + public function testALogASecondAfterTheLastSendGoesAtOnce(): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + + usleep(1_000_000); + $logdash->logger()->info('after a second idle'); + + // A worker that logs once in a while sends each log, rather than keeping it until the next one + $this->assertSame(['after a second idle'], array_column($this->sentLogs(), 'message')); + + $logdash->logger()->info('right after'); + + $this->assertSame(['after a second idle'], array_column($this->sentLogs(), 'message')); + } + + public function testBatchesStayUnderTheApiBodyLimit(): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + + // 4 bytes each, and the API keeps the first 4096 characters + for ($i = 0; $i < 100; $i++) { + $logdash->logger()->info(str_repeat("\u{1F600}", 5000)); + } + $logdash->flush(); + + $batches = $this->receivedBodies('POST /logs/batch'); + + $this->assertGreaterThan(1, count($batches)); + $this->assertCount(100, $this->sentLogs()); + + foreach ($batches as $batch) { + $this->assertLessThan(2_000_000, strlen(json_encode($batch, JSON_UNESCAPED_UNICODE))); + } + + foreach ($this->sentLogs() as $log) { + $this->assertSame(str_repeat("\u{1F600}", 4096), $log['message']); + } + } + + public function testAMessageIsCutAt4096CharactersAndKeepsBadUtf8ForSubstitution(): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + + $logdash->logger()->info(str_repeat('a', 5000)); + $logdash->logger()->info("\xe9" . str_repeat('é', 5000)); + $logdash->flush(); + + $this->assertSame( + [str_repeat('a', 4096), "\u{fffd}" . str_repeat('é', 4095)], + array_column($this->sentLogs(), 'message') + ); + } + + public function testAnHttpClientCanBeInjected(): void + { + $history = []; + $handler = HandlerStack::create(new MockHandler([new Response(200)])); + $handler->push(Middleware::history($history)); + $logdash = Logdash::create([ + 'apiKey' => 'test-api-key', + 'host' => 'https://test.logdash.io', + 'httpClient' => new Client(['handler' => $handler]), + ]); + + $logdash->logger()->info('Test info'); + $logdash->flush(); + + $this->assertCount(1, $history); + $request = $history[0]['request']; + $this->assertSame('https://test.logdash.io/logs/batch', (string) $request->getUri()); + $this->assertSame('test-api-key', $request->getHeaderLine('project-api-key')); + $this->assertSame('application/json', $request->getHeaderLine('Content-Type')); + } + + public function testAForkedChildDoesNotSendItsParentsBuffer(): void + { + if (!function_exists('pcntl_fork')) { + $this->markTestSkipped('Needs pcntl'); + } + + $this->printedBy(sprintf( + '$logdash = Logdash\Logdash::create(%s); + $logdash->metrics()->mutate("jobs", 1); + if (pcntl_fork() === 0) { + $logdash->logger()->info("from the child"); + exit; + } + pcntl_wait($status);', + var_export(['apiKey' => 'test-api-key', 'host' => self::$host], true) + )); + + $this->assertSame(['from the child'], array_column($this->sentLogs(), 'message')); + $this->assertCount(1, $this->receivedRequests('PUT /metrics'), 'The parent sends its mutate once'); + } + + public function testMetricUpdatesMergeTheWayTheApiMergesThem(): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + $metrics = $logdash->metrics(); + + $metrics->set('users', 5); + $metrics->mutate('users', 1); + $metrics->mutate('users', 2); + $metrics->mutate('logins', 1); + $metrics->mutate('logins', 2); + $metrics->mutate('errors', 1); + $metrics->set('errors', 0); + $logdash->flush(); + + // One request per metric, so its updates cannot arrive out of order + $this->assertEquals( + [ + 'users' => ['name' => 'users', 'value' => 8, 'operation' => 'set'], + 'logins' => ['name' => 'logins', 'value' => 3, 'operation' => 'change'], + 'errors' => ['name' => 'errors', 'value' => 0, 'operation' => 'set'], + ], + array_column($this->receivedBodies('PUT /metrics'), null, 'name') + ); + } + + public function testAMetricOrEventThatJsonCannotCarryCostsNothingElse(): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + + $logdash->logger()->info('Test info'); + $logdash->metrics()->set('broken', INF); + $logdash->metrics()->mutate('test_counter', 1); + $logdash->track('agent_run', ['ratio' => NAN]); + $logdash->track('agent_run'); + $logdash->flush(); + + $this->assertCount(1, $this->sentLogs()); + $this->assertSame(['test_counter'], array_column($this->receivedBodies('PUT /metrics'), 'name')); + $this->assertCount(1, $this->receivedRequests('POST /events')); + } + + #[DataProvider('failures')] + public function testLogsAndEventsGetOneRetryForAFailureThatCanPassAndMetricsNone(int $status, int $attempts): void + { + file_put_contents(self::$statusesFile, implode(PHP_EOL, [$status, $status, $status])); + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); + + $logdash->logger()->info('Test info'); + $logdash->metrics()->mutate('test_counter', 1); + $logdash->track('agent_run'); + $logdash->flush(); + + $events = $this->receivedBodies('POST /events'); + + $this->assertCount($attempts, $this->receivedRequests('POST /logs/batch')); + $this->assertCount(1, $this->receivedRequests('PUT /metrics'), 'A retried change could apply twice'); + $this->assertCount($attempts, $events); + $this->assertCount(1, array_unique(array_column($events, 'id')), 'A retry sends the same event ID'); + } + + /** + * @return array + */ + public static function failures(): array + { + return [ + 'server error' => [500, 2], + 'rate limit' => [429, 1], + ]; + } + #[DataProvider('apps')] public function testLogsMetricsAndEventsCarryApp(string $app, string $sentApp): void { @@ -141,8 +392,9 @@ public function testLogsMetricsAndEventsCarryApp(string $app, string $sentApp): $logdash->metrics()->set('test_metric', 42.5); $logdash->metrics()->mutate('test_counter', 1); $logdash->track('agent_run'); + $logdash->flush(); - $this->assertSame([$sentApp, $sentApp], array_column($this->receivedBodies('POST /logs'), 'app')); + $this->assertSame([$sentApp, $sentApp], array_column($this->sentLogs(), 'app')); $this->assertSame([$sentApp, $sentApp], array_column($this->receivedBodies('PUT /metrics'), 'app')); $this->assertSame([$sentApp], array_column($this->receivedBodies('POST /events'), 'app')); } @@ -169,8 +421,9 @@ public function testLogsMetricsAndEventsOmitAppWhenUnset(array $appParams): void $logdash->logger()->info('Test info'); $logdash->metrics()->set('test_metric', 42.5); $logdash->track('agent_run'); + $logdash->flush(); - $logs = $this->receivedBodies('POST /logs'); + $logs = $this->sentLogs(); $metrics = $this->receivedBodies('PUT /metrics'); $events = $this->receivedBodies('POST /events'); @@ -199,6 +452,7 @@ public function testTrackPostsTheEvent(): void $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); $logdash->track('agent_run', ['agent' => 'support', 'steps' => 4, 'success' => true]); + $logdash->flush(); $events = $this->receivedRequests('POST /events'); @@ -218,6 +472,7 @@ public function testEveryEventGetsItsOwnId(): void $logdash->track('agent_run'); $logdash->track('agent_run'); + $logdash->flush(); $ids = array_column($this->receivedBodies('POST /events'), 'id'); @@ -233,6 +488,7 @@ public function testEventsCarryUserId(string|int|null $userId, array $sentUserId $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host]); $logdash->track('agent_run', [], $userId); + $logdash->flush(); $events = $this->receivedBodies('POST /events'); @@ -299,17 +555,40 @@ public function testTrackWithoutApiKeyPrintsTheEventLocallyOnly(): void $this->assertSame([], $this->receivedRequests('POST /events')); } - public function testTrackDoesNotThrowWhenTheApiIsUnreachable(): void + #[DataProvider('unreachableHosts')] + public function testFlushDoesNotThrowWhenTheApiIsUnreachable(string $host): void { - $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => 'http://127.0.0.1:1']); + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => $host]); + $logdash->logger()->info('Test info'); + $logdash->metrics()->set('test_metric', 42.5); $logdash->track('agent_run'); + $logdash->flush(); $this->assertTrue(true); // If we get here, no exceptions were thrown } /** - * @return list> JSON bodies the local server received for $request, such as "POST /logs" + * @return array + */ + public static function unreachableHosts(): array + { + return [ + 'closed port' => ['http://127.0.0.1:1'], + 'no URL' => ['http://:80'], + ]; + } + + /** + * @return list> The logs of every batch the local server received + */ + private function sentLogs(): array + { + return array_merge(...array_column($this->receivedBodies('POST /logs/batch'), 'logs')); + } + + /** + * @return list> JSON bodies the local server received for $request, such as "PUT /metrics" */ private function receivedBodies(string $request): array { diff --git a/tests/server.php b/tests/server.php index d0e570f..7813c99 100644 --- a/tests/server.php +++ b/tests/server.php @@ -12,3 +12,8 @@ ]) . PHP_EOL, FILE_APPEND ); + +// Answers with the status on the first line of LOGDASH_STATUSES_FILE, which it removes, or 200 when there is none +$statuses = file(getenv('LOGDASH_STATUSES_FILE'), FILE_IGNORE_NEW_LINES); +http_response_code((int) (array_shift($statuses) ?? 200)); +file_put_contents(getenv('LOGDASH_STATUSES_FILE'), implode(PHP_EOL, $statuses));