From a83bc921f6d90526d8b7c147198a770a7209d679 Mon Sep 17 00:00:00 2001 From: "malini-agent[bot]" <338999285+malini-agent[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:13:51 +0200 Subject: [PATCH 1/2] Add optional app option sent with every log and metric --- README.md | 16 +++ src/Logdash.php | 5 +- src/Metrics/Metrics.php | 1 + src/Sync/HttpLogSync.php | 1 + src/Types/InitializationParams.php | 8 +- src/Types/RequiredInitializationParams.php | 3 +- tests/LogdashTest.php | 119 +++++++++++++++++++++ tests/server.php | 10 ++ 8 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 tests/server.php diff --git a/README.md b/README.md index 583cd74..3c0b57f 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,21 @@ $metrics->mutate('login_count', 1); // Increment by 1 $metrics->mutate('error_count', -1); // Decrement by 1 ``` +### Apps + +A domain has one API key, the domain key, shared by all apps of that domain, for example a desktop app, an API and a landing page. +Set `app` to name this one, so the dashboard can filter logs and metrics by app: + +```php +$logdash = Logdash::create([ + 'apiKey' => 'your-domain-key', + 'app' => 'api' +]); +``` + +Every log and metric sent by this instance carries the app. +Older service keys ignore `app`. + ### Framework Integration #### Laravel @@ -145,6 +160,7 @@ Creates a new Logdash instance. - `apiKey` (string, optional): Your Logdash API key. If not provided, only local logging will work - `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 and metric. See [Apps](#apps) ### Logger diff --git a/src/Logdash.php b/src/Logdash.php index 98aa5e5..e87c623 100644 --- a/src/Logdash.php +++ b/src/Logdash.php @@ -26,14 +26,15 @@ private function __construct( } /** - * @param array{apiKey?:string, host?:string, verbose?:bool}|null $params + * @param array{apiKey?:string, host?:string, verbose?:bool, app?:string}|null $params */ public static function create(?array $params = null): self { $initParams = new InitializationParams( apiKey: $params['apiKey'] ?? null, host: $params['host'] ?? 'https://api.logdash.io', - verbose: $params['verbose'] ?? false + verbose: $params['verbose'] ?? false, + app: $params['app'] ?? null ); $requiredParams = $initParams->toRequired(); diff --git a/src/Metrics/Metrics.php b/src/Metrics/Metrics.php index 4dc6e4a..22bd087 100644 --- a/src/Metrics/Metrics.php +++ b/src/Metrics/Metrics.php @@ -49,6 +49,7 @@ private function sendMetric(string $name, float $value, MetricOperation $operati 'name' => $name, 'value' => $value, 'operation' => $operation->value, + ...($this->params->app === null ? [] : ['app' => $this->params->app]), ], 'timeout' => 5, ]); diff --git a/src/Sync/HttpLogSync.php b/src/Sync/HttpLogSync.php index 2eb9896..507220e 100644 --- a/src/Sync/HttpLogSync.php +++ b/src/Sync/HttpLogSync.php @@ -40,6 +40,7 @@ public function send(string $message, LogLevel $level, string $createdAt): void 'level' => $level->value, 'createdAt' => $createdAt, 'sequenceNumber' => $this->sequenceNumber++, + ...($this->params->app === null ? [] : ['app' => $this->params->app]), ], 'timeout' => 5, ]); diff --git a/src/Types/InitializationParams.php b/src/Types/InitializationParams.php index 6fceda1..efd1fc7 100644 --- a/src/Types/InitializationParams.php +++ b/src/Types/InitializationParams.php @@ -9,16 +9,20 @@ class InitializationParams public function __construct( public readonly ?string $apiKey = null, public readonly string $host = 'https://api.logdash.io', - public readonly bool $verbose = false + public readonly bool $verbose = false, + public readonly ?string $app = null ) { } public function toRequired(): RequiredInitializationParams { + $app = trim($this->app ?? ''); + return new RequiredInitializationParams( apiKey: $this->apiKey ?? '', host: $this->host, - verbose: $this->verbose + verbose: $this->verbose, + app: $app === '' ? null : $app ); } } diff --git a/src/Types/RequiredInitializationParams.php b/src/Types/RequiredInitializationParams.php index ae21d81..2c0c1be 100644 --- a/src/Types/RequiredInitializationParams.php +++ b/src/Types/RequiredInitializationParams.php @@ -7,7 +7,8 @@ class RequiredInitializationParams public function __construct( public readonly string $apiKey, public readonly string $host, - public readonly bool $verbose + public readonly bool $verbose, + public readonly ?string $app = null ) { } } diff --git a/tests/LogdashTest.php b/tests/LogdashTest.php index d10dfb3..f5a66b2 100644 --- a/tests/LogdashTest.php +++ b/tests/LogdashTest.php @@ -6,10 +6,50 @@ use Logdash\Logdash; use Logdash\Types\LogLevel; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class LogdashTest extends TestCase { + /** @var resource */ + private static $server; + private static string $host; + private static string $requestsFile; + + 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'); + $socket = stream_socket_server('tcp://127.0.0.1:0'); + $address = stream_socket_get_name($socket, false); + fclose($socket); + + self::$host = "http://{$address}"; + self::$server = proc_open( + [PHP_BINARY, '-S', $address, __DIR__ . '/server.php'], + [['null'], ['null'], ['null']], + $pipes, + null, + ['LOGDASH_REQUESTS_FILE' => self::$requestsFile] + getenv() + ); + + for ($attempt = 0; $attempt < 100 && @stream_socket_client("tcp://{$address}") === false; $attempt++) { + usleep(50_000); + } + } + + public static function tearDownAfterClass(): void + { + proc_terminate(self::$server); + proc_close(self::$server); + unlink(self::$requestsFile); + } + + protected function setUp(): void + { + file_put_contents(self::$requestsFile, ''); + } + public function testCreateWithoutApiKey(): void { $logdash = Logdash::create(); @@ -72,4 +112,83 @@ public function testLogLevelEnum(): void $this->assertEquals('debug', LogLevel::DEBUG->value); $this->assertEquals('silly', LogLevel::SILLY->value); } + + #[DataProvider('apps')] + public function testLogsAndMetricsCarryApp(string $app, string $sentApp): void + { + $logdash = Logdash::create([ + 'apiKey' => 'test-api-key', + 'host' => self::$host, + 'app' => $app + ]); + + $logdash->logger()->info('Test info'); + $logdash->logger()->error('Test error'); + $logdash->metrics()->set('test_metric', 42.5); + $logdash->metrics()->mutate('test_counter', 1); + + $this->assertSame([$sentApp, $sentApp], array_column($this->receivedBodies('POST /logs'), 'app')); + $this->assertSame([$sentApp, $sentApp], array_column($this->receivedBodies('PUT /metrics'), 'app')); + } + + /** + * @return array + */ + public static function apps(): array + { + return [ + 'app' => ['api', 'api'], + 'padded app' => [' api ', 'api'], + ]; + } + + /** + * @param array{app?: string} $appParams + */ + #[DataProvider('unsetApps')] + public function testLogsAndMetricsOmitAppWhenUnset(array $appParams): void + { + $logdash = Logdash::create(['apiKey' => 'test-api-key', 'host' => self::$host] + $appParams); + + $logdash->logger()->info('Test info'); + $logdash->metrics()->set('test_metric', 42.5); + + $logs = $this->receivedBodies('POST /logs'); + $metrics = $this->receivedBodies('PUT /metrics'); + + $this->assertCount(1, $logs); + $this->assertArrayNotHasKey('app', $logs[0]); + $this->assertCount(1, $metrics); + $this->assertArrayNotHasKey('app', $metrics[0]); + } + + /** + * @return array + */ + public static function unsetApps(): array + { + return [ + 'no app' => [[]], + 'empty app' => [['app' => '']], + 'blank app' => [['app' => ' ']], + ]; + } + + /** + * @return list> JSON bodies the local server received for $request, such as "POST /logs" + */ + private function receivedBodies(string $request): array + { + $bodies = []; + + foreach (file(self::$requestsFile, FILE_IGNORE_NEW_LINES) as $line) { + [$method, $path, $body] = explode(' ', $line, 3); + + if ("{$method} {$path}" === $request) { + $bodies[] = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } + } + + return $bodies; + } } diff --git a/tests/server.php b/tests/server.php new file mode 100644 index 0000000..fa7231b --- /dev/null +++ b/tests/server.php @@ -0,0 +1,10 @@ + Date: Sat, 24 Oct 2026 11:16:06 +0200 Subject: [PATCH 2/2] Run PHPUnit in CI and fix actionlint findings in the workflow --- .github/workflows/publish-package.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 58f65b5..fb79c72 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -32,7 +32,7 @@ jobs: - name: Cache Composer packages id: composer-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: vendor key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} @@ -48,6 +48,9 @@ jobs: - 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 @@ -70,16 +73,16 @@ jobs: id: create_tag run: | VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;") - echo "version=$VERSION" >> $GITHUB_OUTPUT + 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 + 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 + echo "tag_exists=false" >> "$GITHUB_OUTPUT" fi - name: Trigger Packagist update