diff --git a/.codecov.yaml b/.codecov.yaml index 1755600a59..bbbf5a2565 100644 --- a/.codecov.yaml +++ b/.codecov.yaml @@ -99,6 +99,10 @@ component_management: name: bridge-psr18-telemetry paths: - src/bridge/psr18/telemetry/** + - component_id: bridge-psr3-telemetry + name: bridge-psr3-telemetry + paths: + - src/bridge/psr3/telemetry/** - component_id: bridge-psr7-telemetry name: bridge-psr7-telemetry paths: diff --git a/.github/workflows/monorepo-split.yml b/.github/workflows/monorepo-split.yml index 2233e0bea9..ba1c16c636 100644 --- a/.github/workflows/monorepo-split.yml +++ b/.github/workflows/monorepo-split.yml @@ -88,6 +88,8 @@ jobs: split_repository: 'openapi-specification-bridge' - local_path: 'src/bridge/postgresql/valinor' split_repository: 'postgresql-valinor-bridge' + - local_path: 'src/bridge/psr3/telemetry' + split_repository: 'psr3-telemetry-bridge' - local_path: 'src/bridge/psr7/telemetry' split_repository: 'psr7-telemetry-bridge' - local_path: 'src/bridge/psr18/telemetry' diff --git a/composer.json b/composer.json index caad024f22..6b122bd5c8 100644 --- a/composer.json +++ b/composer.json @@ -106,6 +106,7 @@ "flow-php/parquet-viewer": "self.version", "flow-php/postgresql": "self.version", "flow-php/postgresql-valinor-bridge": "self.version", + "flow-php/psr3-telemetry-bridge": "self.version", "flow-php/psr7-telemetry-bridge": "self.version", "flow-php/psr18-telemetry-bridge": "self.version", "flow-php/snappy": "self.version", @@ -150,6 +151,7 @@ "src/bridge/monolog/telemetry/src/Flow", "src/bridge/openapi/specification/src/Flow", "src/bridge/postgresql/valinor/src/Flow", + "src/bridge/psr3/telemetry/src/Flow", "src/bridge/psr7/telemetry/src/Flow", "src/bridge/psr18/telemetry/src/Flow", "src/bridge/symfony/filesystem-bundle/src/Flow", @@ -208,6 +210,7 @@ "src/bridge/phpunit/postgresql/src/Flow/Bridge/PHPUnit/PostgreSQL/DSL/functions.php", "src/bridge/openapi/specification/src/Flow/Bridge/OpenAPI/Specification/DSL/functions.php", "src/bridge/postgresql/valinor/src/Flow/PostgreSql/Bridge/Valinor/DSL/functions.php", + "src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/DSL/functions.php", "src/bridge/psr7/telemetry/src/Flow/Bridge/Psr7/Telemetry/DSL/functions.php", "src/bridge/psr18/telemetry/src/Flow/Bridge/Psr18/Telemetry/DSL/functions.php", "src/bridge/symfony/http-foundation-telemetry/src/Flow/Bridge/Symfony/HttpFoundationTelemetry/DSL/functions.php", @@ -259,6 +262,7 @@ "src/bridge/monolog/telemetry/tests/Flow", "src/bridge/openapi/specification/tests/Flow", "src/bridge/postgresql/valinor/tests/Flow", + "src/bridge/psr3/telemetry/tests/Flow", "src/bridge/psr7/telemetry/tests/Flow", "src/bridge/psr18/telemetry/tests/Flow", "src/bridge/symfony/filesystem-bundle/tests/Flow", @@ -375,6 +379,8 @@ "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/lib.parquet.xml", "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/lib.parquet-viewer.xml", "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/lib.postgresql.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/lib.postgresql-migrations.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/lib.rdsl.xml", "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/lib.snappy.xml", "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/lib.telemetry.xml", "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/lib.types.xml", @@ -394,9 +400,22 @@ "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.filesystem.async-aws.xml", "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.filesystem.azure.xml", "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.monolog.http.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.monolog-telemetry.xml", "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.openapi.specification.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.phpunit.postgresql.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.phpunit.telemetry.xml", "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.postgresql.valinor.xml", - "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.symfony.http-foundation.xml" + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.psr3.telemetry.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.psr7.telemetry.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.psr18.telemetry.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.symfony.filesystem.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.symfony.filesystem-cache.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.symfony.http-foundation.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.symfony.postgresql-cache.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.symfony.postgresql-messenger.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.symfony.postgresql-migrations.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.symfony.postgresql-session.xml", + "./tools/phpdocumentor/vendor/bin/phpdoc --config=./phpdoc/bridge.symfony.telemetry.xml" ], "build:parquet:thrift": [ "grep -q 'namespace php Flow.Parquet.ThriftModel' src/lib/parquet/src/Flow/Parquet/Resources/Thrift/parquet.thrift || { echo \"Flow php namespace not found in thrift definition!\"; exit 1; }\n", diff --git a/composer.lock b/composer.lock index 5953fb1896..957fd2b2e5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "56cf2928ca79e0dee7579628d24695bb", + "content-hash": "7bf843de65f7d9eea6d21112ca678bef", "packages": [ { "name": "async-aws/core", @@ -753,16 +753,16 @@ }, { "name": "google/apiclient-services", - "version": "v0.439.0", + "version": "v0.440.0", "source": { "type": "git", "url": "https://github.com/googleapis/google-api-php-client-services.git", - "reference": "1ce71ed1e35d5998f8a6e39475cf398f62bb25c7" + "reference": "f835f7a84611071ca2f58e8f44aac497d3aa7c44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/1ce71ed1e35d5998f8a6e39475cf398f62bb25c7", - "reference": "1ce71ed1e35d5998f8a6e39475cf398f62bb25c7", + "url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/f835f7a84611071ca2f58e8f44aac497d3aa7c44", + "reference": "f835f7a84611071ca2f58e8f44aac497d3aa7c44", "shasum": "" }, "require": { @@ -791,9 +791,9 @@ ], "support": { "issues": "https://github.com/googleapis/google-api-php-client-services/issues", - "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.439.0" + "source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.440.0" }, - "time": "2026-04-26T01:20:24+00:00" + "time": "2026-05-04T01:36:24+00:00" }, { "name": "google/auth", diff --git a/documentation/components/bridges/psr3-telemetry-bridge.md b/documentation/components/bridges/psr3-telemetry-bridge.md new file mode 100644 index 0000000000..8ee0668885 --- /dev/null +++ b/documentation/components/bridges/psr3-telemetry-bridge.md @@ -0,0 +1,194 @@ +# PSR-3 Telemetry Bridge + +Flow PSR-3 Telemetry Bridge exposes a [PSR-3](https://www.php-fig.org/psr/psr-3/) `LoggerInterface` implementation +backed by Flow PHP Telemetry. Any framework or library that depends on `Psr\Log\LoggerInterface` can write through this +adapter and have its records emitted as Telemetry log signals. + +- [⬅️️ Back](/documentation/introduction.md) +- [📦Packagist](https://packagist.org/packages/flow-php/psr3-telemetry-bridge) +- [➡️ Installation](/documentation/installation/packages/psr3-telemetry-bridge.md) +- [🐙GitHub](https://github.com/flow-php/psr3-telemetry-bridge) +- [📚API Reference](/documentation/api/bridge/psr3/telemetry) +- [🗺DSL](/documentation/api/bridge/psr3/telemetry/namespaces/flow-bridge-psr3-telemetry-dsl.html) + +[TOC] + +## Installation + +For detailed installation instructions, see the [installation page](/documentation/installation/packages/psr3-telemetry-bridge.md). + +## Overview + +This bridge wraps `Flow\Telemetry\Logger\Logger` behind a PSR-3 `LoggerInterface`. It: + +- Maps PSR-3 log levels to Telemetry severities (8 → 6, customizable). +- Performs PSR-3 `{placeholder}` interpolation against the context array (scalars and `Stringable` only). +- Stores every context entry as a log record attribute under its raw key. +- Routes a `Throwable` under the `exception` key through `LogRecord::setException()`, populating + `exception.type`, `exception.message`, and `exception.stacktrace`. +- Throws `Psr\Log\InvalidArgumentException` when `log()` is called with an unknown level, per spec. + +## Basic Usage + +```php +logger('my-service')); + +$psrLogger->info('User {user_id} logged in', ['user_id' => 123]); +// → body: 'User 123 logged in' +// → severity: INFO +// → attributes: {user_id: 123} +``` + +The returned `TelemetryLogger` is a drop-in `Psr\Log\LoggerInterface`, so it can be passed to anything that accepts a +PSR-3 logger (Symfony, Laravel, third-party libraries). + +## Severity Mapping + +The default mapping collapses PSR-3's eight levels onto Telemetry's six OTEL-aligned severities: + +| PSR-3 Level | Telemetry Severity | +|-------------|--------------------| +| `debug` | `DEBUG` | +| `info` | `INFO` | +| `notice` | `INFO` | +| `warning` | `WARN` | +| `error` | `ERROR` | +| `critical` | `FATAL` | +| `alert` | `FATAL` | +| `emergency` | `FATAL` | + +Override the mapping by passing a custom `psr3_severity_mapper()` to `psr3_log_record_converter()`: + +```php + Severity::TRACE, + LogLevel::INFO => Severity::INFO, + LogLevel::NOTICE => Severity::WARN, + LogLevel::WARNING => Severity::WARN, + LogLevel::ERROR => Severity::ERROR, + LogLevel::CRITICAL => Severity::FATAL, + LogLevel::ALERT => Severity::FATAL, + LogLevel::EMERGENCY => Severity::FATAL, + ]), +); + +$psrLogger = psr3_telemetry_logger($telemetry->logger('my-service'), $converter); +``` + +## Context Handling + +### Attributes + +Every context entry becomes an attribute on the emitted `LogRecord`, keyed verbatim: + +```php +$psrLogger->info('Request processed', [ + 'http.method' => 'GET', + 'http.status' => 200, + 'duration_ms' => 12.4, +]); +// attributes: {http.method: 'GET', http.status: 200, duration_ms: 12.4} +``` + +Non-scalar values are normalized via `ValueNormalizer`: + +- `null` → `'null'` +- `DateTimeInterface`, `Throwable` → passed through +- arrays → recursively normalized +- objects with `__toString()` → string cast +- objects without `__toString()` → class name +- other types → `get_debug_type()` result + +### Exceptions + +A `Throwable` under the `exception` key is unpacked into the standard OTEL exception attributes via +`LogRecord::setException()`: + +```php +try { + // ... +} catch (\Throwable $e) { + $psrLogger->error('Operation failed', ['exception' => $e]); +} +// attributes: +// exception.type: 'RuntimeException' +// exception.message: '...' +// exception.stacktrace: '...' +``` + +The original `exception` key is **not** stored as a separate attribute. A non-`Throwable` value under `exception` is +treated as a regular attribute. + +### Message Interpolation + +Per PSR-3 §1.2, `{placeholder}` tokens in the message body are substituted with matching context entries. Only scalars +and `Stringable` objects participate; arrays, `Throwable` instances, and plain objects are left in the template. The +context entries remain available as attributes regardless. + +```php +$psrLogger->warning('Quota exceeded for {tenant} ({used}/{limit})', [ + 'tenant' => 'acme', + 'used' => 105, + 'limit' => 100, +]); +// body: 'Quota exceeded for acme (105/100)' +// attributes: {tenant: 'acme', used: 105, limit: 100} +``` + +## DSL Functions + +### psr3_telemetry_logger() + +Wraps a Flow Telemetry `Logger` in a PSR-3 `LoggerInterface`. + +```php +$psrLogger = psr3_telemetry_logger( + logger: $telemetry->logger('my-service'), + converter: $converter, // optional, defaults to LogRecordConverter() +); +``` + +### psr3_log_record_converter() + +Builds the converter that turns each PSR-3 call into a `LogRecord`. Use it to plug in a custom severity mapper or value +normalizer. + +```php +$converter = psr3_log_record_converter( + severityMapper: psr3_severity_mapper([...]), + valueNormalizer: psr3_value_normalizer(), +); +``` + +### psr3_severity_mapper() + +Builds a `SeverityMapper`. Pass `null` (default) to use the standard PSR-3 → OTEL mapping, or a full PSR-3 → Severity +array to override. + +```php +$mapper = psr3_severity_mapper([ + LogLevel::DEBUG => Severity::TRACE, + // ... rest of PSR-3 levels +]); +``` + +### psr3_value_normalizer() + +Builds the default `ValueNormalizer`. Provided for symmetry with the other DSL helpers; most users won't need it. + +```php +$normalizer = psr3_value_normalizer(); +``` diff --git a/documentation/components/bridges/symfony-telemetry-bundle.md b/documentation/components/bridges/symfony-telemetry-bundle.md index b6583531b1..6d918fad7c 100644 --- a/documentation/components/bridges/symfony-telemetry-bundle.md +++ b/documentation/components/bridges/symfony-telemetry-bundle.md @@ -42,7 +42,7 @@ flow_telemetry: static: cache: enabled: true # Cache static attributes (default: true) - path: null # Cache path (default: kernel cache dir) + path: null # Cache file path (default: sys_get_temp_dir()/flow_telemetry_resource.cache) os: enabled: true # Detect os.type, os.name, os.version, os.description host: @@ -75,6 +75,12 @@ flow_telemetry: Static detectors are cached by default. Dynamic detectors run on every request/command. +The cache file lives outside Symfony's cache lifecycle on purpose: building the Symfony cache +(via `cache:warmup`) at image build time would otherwise freeze runtime-dependent attributes +such as `host.name` or `process.pid` from the build container. Defaulting to +`sys_get_temp_dir()` keeps the cache per-runtime and avoids that pitfall. To invalidate it, +delete the cache file or restart the process; `cache:clear` does not touch it. + Custom attributes override auto-detected values. ### Clock Configuration @@ -660,6 +666,33 @@ flow_telemetry: version: '1.0.0' # default: 'unknown' ``` +### Main Logger + +The bundle depends on [PSR-3 Telemetry Bridge](/documentation/components/bridges/psr3-telemetry-bridge.md) and registers a PSR-3 wrapper service for every named Telemetry logger at `flow.telemetry..logger.psr3`. This makes Flow Telemetry loggers usable as Symfony's `logger` service, removing the need for Monolog when telemetry is the only logging destination. + +In addition, the bundle always registers a `default` logger, meter, and tracer — `flow.telemetry.default.logger`, `flow.telemetry.default.logger.psr3`, `flow.telemetry.default.meter`, `flow.telemetry.default.tracer` — regardless of what is configured under `loggers`/`meters`/`tracers`. Defining your own `default` entry under those keys is allowed and will override the auto-default. + +**Options:** + +| Option | Type | Default | Description | +|--------------------|------------------|---------|--------------------------------------------------------------------------------------------------------------| +| `framework_logger` | `string \| null` | `null` | Name of a logger configured under `loggers` (or the always-available `default`) to alias as Symfony `logger` | + +**Behavior:** + +- When `framework_logger` is set, the bundle aliases the Symfony `logger` service to `flow.telemetry..logger.psr3`. If no logger with that name exists, container compilation fails with a clear error. +- When `framework_logger` is `null` and Symfony's `logger` service is the default `Symfony\Component\HttpKernel\Log\Logger`, the bundle automatically aliases `logger` to `flow.telemetry.default.logger.psr3`. +- When `framework_logger` is `null` and `logger` is provided by another bundle (Monolog, custom alias, etc.), the bundle leaves `logger` alone. + +```yaml +flow_telemetry: + loggers: + app: + version: '1.0.0' + + framework_logger: app # Symfony "logger" service -> flow.telemetry.app.logger.psr3 +``` + ## Pattern Matching Several configuration options support pattern matching for exclusion lists (paths, commands, templates, etc.). @@ -840,6 +873,22 @@ flow_telemetry: transport: endpoint: '%env(OTEL_EXPORTER_OTLP_LOGS_ENDPOINT)%' + loggers: + app: + version: '%env(APP_VERSION)%' + audit: + version: '%env(APP_VERSION)%' + attributes: + channel: audit + meters: + business: + version: '%env(APP_VERSION)%' + tracers: + checkout: + version: '%env(APP_VERSION)%' + + framework_logger: app # Symfony "logger" service -> flow.telemetry.app.logger.psr3 + instrumentation: http_kernel: enabled: true diff --git a/documentation/installation/packages/psr3-telemetry-bridge.md b/documentation/installation/packages/psr3-telemetry-bridge.md new file mode 100644 index 0000000000..6109c55833 --- /dev/null +++ b/documentation/installation/packages/psr3-telemetry-bridge.md @@ -0,0 +1,24 @@ +--- +seo_title: "Installing PSR-3 Telemetry Bridge" +seo_description: > + How to install flow-php/psr3-telemetry-bridge in your PHP project using Composer. +--- + +# PSR-3 Telemetry Bridge + +- [⬅️️ Back](/documentation/installation.md) +- [📜 Documentation](/documentation/components/bridges/psr3-telemetry-bridge.md) +- [📦 Packagist](https://packagist.org/packages/flow-php/psr3-telemetry-bridge) + +[TOC] + +## Composer + +```bash +composer require flow-php/psr3-telemetry-bridge:~--FLOW_PHP_VERSION-- +``` + +## Core Dependencies + +- [psr/log](https://packagist.org/packages/psr/log) +- [flow-php/telemetry](https://packagist.org/packages/flow-php/telemetry) diff --git a/manifest.json b/manifest.json index c16d48d046..ddd0616d0e 100644 --- a/manifest.json +++ b/manifest.json @@ -176,6 +176,11 @@ "path": "src/bridge/psr18/telemetry", "type": "bridge" }, + { + "name": "flow-php/psr3-telemetry-bridge", + "path": "src/bridge/psr3/telemetry", + "type": "bridge" + }, { "name": "flow-php/psr7-telemetry-bridge", "path": "src/bridge/psr7/telemetry", diff --git a/phpdoc/bridge.psr3.telemetry.xml b/phpdoc/bridge.psr3.telemetry.xml new file mode 100644 index 0000000000..60b6778132 --- /dev/null +++ b/phpdoc/bridge.psr3.telemetry.xml @@ -0,0 +1,24 @@ + + + Flow PHP + + ./../web/landing/build/documentation/api/bridge/psr3/telemetry + ./../var/phpdocumentor/cache/bridge/psr3/telemetry + + + + + src/bridge/psr3/telemetry/src + + telemetry + PSR-3 Telemetry + public + false + + + + diff --git a/phpdoc/bridge.psr7.telemetry.xml b/phpdoc/bridge.psr7.telemetry.xml new file mode 100644 index 0000000000..c9b16bd6c6 --- /dev/null +++ b/phpdoc/bridge.psr7.telemetry.xml @@ -0,0 +1,24 @@ + + + Flow PHP + + ./../web/landing/build/documentation/api/bridge/psr7/telemetry + ./../var/phpdocumentor/cache/bridge/psr7/telemetry + + + + + src/bridge/psr7/telemetry/src + + telemetry + PSR-7 Telemetry + public + false + + + + diff --git a/phpstan.neon b/phpstan.neon index c48bdf8afa..a788a85e7e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -28,6 +28,7 @@ parameters: - src/bridge/monolog/telemetry/src - src/bridge/openapi/specification/src - src/bridge/postgresql/valinor/src + - src/bridge/psr3/telemetry/src - src/bridge/psr7/telemetry/src - src/bridge/psr18/telemetry/src - src/bridge/symfony/http-foundation/src @@ -77,6 +78,7 @@ parameters: - src/bridge/monolog/telemetry/tests - src/bridge/openapi/specification/tests - src/bridge/postgresql/valinor/tests + - src/bridge/psr3/telemetry/tests - src/bridge/psr7/telemetry/tests - src/bridge/psr18/telemetry/tests - src/bridge/symfony/http-foundation/tests diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c43f354cca..f827baf8fd 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -135,6 +135,12 @@ src/bridge/postgresql/valinor/tests/Flow/PostgreSql/Bridge/Valinor/Tests/Unit + + src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit + + + src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Integration + src/bridge/psr7/telemetry/tests/Flow/Bridge/Psr7/Telemetry/Tests/Unit diff --git a/src/bridge/psr3/telemetry/.gitattributes b/src/bridge/psr3/telemetry/.gitattributes new file mode 100644 index 0000000000..e020972059 --- /dev/null +++ b/src/bridge/psr3/telemetry/.gitattributes @@ -0,0 +1,9 @@ +*.php text eol=lf + +/.github export-ignore +/tests export-ignore + +/README.md export-ignore + +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/bridge/psr3/telemetry/.github/workflows/readonly.yaml b/src/bridge/psr3/telemetry/.github/workflows/readonly.yaml new file mode 100644 index 0000000000..24255888e1 --- /dev/null +++ b/src/bridge/psr3/telemetry/.github/workflows/readonly.yaml @@ -0,0 +1,17 @@ +name: Readonly + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Hi, thank you for your contribution. + Unfortunately, this repository is read-only. It's a split from our main monorepo repository. + In order to proceed with this PR please open it against https://github.com/flow-php/flow repository. + Thank you. diff --git a/src/bridge/psr3/telemetry/CONTRIBUTING.md b/src/bridge/psr3/telemetry/CONTRIBUTING.md new file mode 100644 index 0000000000..f035b534af --- /dev/null +++ b/src/bridge/psr3/telemetry/CONTRIBUTING.md @@ -0,0 +1,6 @@ +## Contributing + +This repo is **READ ONLY**, in order to contribute to Flow PHP project, please +open PR against [flow](https://github.com/flow-php/flow) monorepo. + +Changes merged to monorepo are automatically propagated into sub repositories. diff --git a/src/bridge/psr3/telemetry/LICENSE b/src/bridge/psr3/telemetry/LICENSE new file mode 100644 index 0000000000..bc3cc4d085 --- /dev/null +++ b/src/bridge/psr3/telemetry/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-present Flow PHP + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/bridge/psr3/telemetry/README.md b/src/bridge/psr3/telemetry/README.md new file mode 100644 index 0000000000..1132699a65 --- /dev/null +++ b/src/bridge/psr3/telemetry/README.md @@ -0,0 +1,11 @@ +# PSR-3 Telemetry Bridge + +Bridge connecting Flow PHP Telemetry library with PSR-3 logger interface, allowing log records to be emitted as telemetry log signals. + +> [!IMPORTANT] +> This repository is a subtree split from our monorepo. If you'd like to contribute, please visit our main monorepo [flow-php/flow](https://github.com/flow-php/flow). + +- 📜 [Documentation](https://flow-php.com/documentation/components/bridges/psr3-telemetry-bridge/) +- ➡️ [Installation](https://flow-php.com/documentation/installation/packages/psr3-telemetry-bridge/) +- 🛠️ [Contributing](https://flow-php.com/documentation/contributing/) +- 🚧 [Upgrading](https://flow-php.com/documentation/upgrading/) diff --git a/src/bridge/psr3/telemetry/composer.json b/src/bridge/psr3/telemetry/composer.json new file mode 100644 index 0000000000..c067d91663 --- /dev/null +++ b/src/bridge/psr3/telemetry/composer.json @@ -0,0 +1,39 @@ +{ + "name": "flow-php/psr3-telemetry-bridge", + "type": "library", + "description": "Flow PHP - PSR-3 Telemetry Bridge", + "keywords": [ + "flow-php", + "psr-3", + "telemetry", + "bridge" + ], + "homepage": "https://github.com/flow-php/flow", + "license": "MIT", + "require": { + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "flow-php/telemetry": "self.version", + "psr/log": "^2.0 || ^3.0" + }, + "autoload": { + "psr-4": { + "Flow\\": [ + "src/Flow" + ] + }, + "files": [ + "src/Flow/Bridge/Psr3/Telemetry/DSL/functions.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Flow\\": "tests/Flow" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "optimize-autoloader": true, + "sort-packages": true + } +} diff --git a/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/DSL/functions.php b/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/DSL/functions.php new file mode 100644 index 0000000000..eb932ed2fe --- /dev/null +++ b/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/DSL/functions.php @@ -0,0 +1,89 @@ +logger('my-service')); + * $psrLogger->info('User {user_id} logged in', ['user_id' => 123]); + * ``` + */ +#[DocumentationDSL(module: Module::PSR3_TELEMETRY_BRIDGE, type: DSLType::HELPER)] +function psr3_telemetry_logger( + Logger $logger, + LogRecordConverter $converter = new LogRecordConverter(), +) : TelemetryLogger { + return new TelemetryLogger($logger, $converter); +} + +/** + * Create a SeverityMapper for mapping PSR-3 LogLevel strings to Telemetry Severity. + * + * @param null|array $customMapping Optional override (PSR-3 LogLevel string => Severity) + * + * Example with custom mapping: + * ```php + * use Psr\Log\LogLevel; + * use Flow\Telemetry\Logger\Severity; + * + * $mapper = psr3_severity_mapper([ + * LogLevel::DEBUG => Severity::TRACE, + * LogLevel::INFO => Severity::INFO, + * LogLevel::NOTICE => Severity::WARN, + * LogLevel::WARNING => Severity::WARN, + * LogLevel::ERROR => Severity::ERROR, + * LogLevel::CRITICAL => Severity::FATAL, + * LogLevel::ALERT => Severity::FATAL, + * LogLevel::EMERGENCY => Severity::FATAL, + * ]); + * ``` + */ +#[DocumentationDSL(module: Module::PSR3_TELEMETRY_BRIDGE, type: DSLType::HELPER)] +function psr3_severity_mapper(?array $customMapping = null) : SeverityMapper +{ + return new SeverityMapper($customMapping); +} + +/** + * Create a LogRecordConverter that turns PSR-3 calls into Telemetry LogRecords. + * + * The converter: + * - Maps the PSR-3 level to a {@see Severity} via the provided mapper. + * - Substitutes `{placeholder}` tokens in the message body using context entries + * (scalars and Stringable objects only). + * - Stores every context entry as an attribute under its raw key. + * - Routes a `Throwable` under the `exception` key through `setException()`. + */ +#[DocumentationDSL(module: Module::PSR3_TELEMETRY_BRIDGE, type: DSLType::HELPER)] +function psr3_log_record_converter( + ?SeverityMapper $severityMapper = null, + ?ValueNormalizer $valueNormalizer = null, +) : LogRecordConverter { + return new LogRecordConverter( + $severityMapper ?? psr3_severity_mapper(), + $valueNormalizer ?? psr3_value_normalizer(), + ); +} + +/** + * Create a ValueNormalizer for converting arbitrary PHP values into Telemetry attribute types. + */ +#[DocumentationDSL(module: Module::PSR3_TELEMETRY_BRIDGE, type: DSLType::HELPER)] +function psr3_value_normalizer() : ValueNormalizer +{ + return new ValueNormalizer(); +} diff --git a/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/Exception/InvalidArgumentException.php b/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000..69b58eda70 --- /dev/null +++ b/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/Exception/InvalidArgumentException.php @@ -0,0 +1,11 @@ + $context + */ + public function convert(string|\Stringable $level, string|\Stringable $message, array $context = []) : LogRecord + { + $record = new LogRecord( + severity: $this->severityMapper->map($level), + body: $this->interpolate((string) $message, $context), + ); + + return $this->applyContext($record, $context); + } + + /** + * @param array $context + */ + private function applyContext(LogRecord $record, array $context) : LogRecord + { + foreach ($context as $key => $value) { + $key = (string) $key; + + if ($key === 'exception' && $value instanceof \Throwable) { + $record = $record->setException($value); + + continue; + } + + $record = $record->setAttribute($key, $this->valueNormalizer->normalize($value)); + } + + return $record; + } + + /** + * @param array $context + */ + private function interpolate(string $message, array $context) : string + { + if ($message === '' || !str_contains($message, '{')) { + return $message; + } + + $replacements = []; + + foreach ($context as $key => $value) { + $rendered = $this->renderForInterpolation($value); + + if ($rendered === null) { + continue; + } + + $replacements['{' . $key . '}'] = $rendered; + } + + if ($replacements === []) { + return $message; + } + + return strtr($message, $replacements); + } + + private function renderForInterpolation(mixed $value) : ?string + { + if ($value === null) { + return ''; + } + + if (\is_bool($value)) { + return $value ? '1' : ''; + } + + if (\is_scalar($value)) { + return (string) $value; + } + + if ($value instanceof \Throwable) { + return null; + } + + if (\is_object($value) && \method_exists($value, '__toString')) { + return (string) $value; + } + + return null; + } +} diff --git a/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/SeverityMapper.php b/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/SeverityMapper.php new file mode 100644 index 0000000000..db5b87a372 --- /dev/null +++ b/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/SeverityMapper.php @@ -0,0 +1,56 @@ + + */ + private array $mapping; + + /** + * @param null|array $customMapping Optional custom mapping (PSR-3 LogLevel string => Telemetry Severity) + */ + public function __construct(?array $customMapping = null) + { + $this->mapping = $customMapping ?? self::defaultMapping(); + } + + /** + * @return array + */ + public static function defaultMapping() : array + { + return [ + LogLevel::DEBUG => Severity::DEBUG, + LogLevel::INFO => Severity::INFO, + LogLevel::NOTICE => Severity::INFO, + LogLevel::WARNING => Severity::WARN, + LogLevel::ERROR => Severity::ERROR, + LogLevel::CRITICAL => Severity::FATAL, + LogLevel::ALERT => Severity::FATAL, + LogLevel::EMERGENCY => Severity::FATAL, + ]; + } + + /** + * @throws InvalidArgumentException When the level is not a known PSR-3 LogLevel string + */ + public function map(string|\Stringable $level) : Severity + { + $key = (string) $level; + + if (!array_key_exists($key, $this->mapping)) { + throw new InvalidArgumentException("Unknown PSR-3 log level: {$key}"); + } + + return $this->mapping[$key]; + } +} diff --git a/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/TelemetryLogger.php b/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/TelemetryLogger.php new file mode 100644 index 0000000000..7cd1d97cbd --- /dev/null +++ b/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/TelemetryLogger.php @@ -0,0 +1,30 @@ + $context + */ + public function log($level, string|\Stringable $message, array $context = []) : void + { + if (!\is_string($level) && !$level instanceof \Stringable) { + throw new InvalidArgumentException('PSR-3 log level must be a string or Stringable.'); + } + + $this->logger->emit($this->converter->convert($level, $message, $context)); + } +} diff --git a/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/ValueNormalizer.php b/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/ValueNormalizer.php new file mode 100644 index 0000000000..9afadb0fbf --- /dev/null +++ b/src/bridge/psr3/telemetry/src/Flow/Bridge/Psr3/Telemetry/ValueNormalizer.php @@ -0,0 +1,47 @@ +|bool|\DateTimeInterface|float|int|string|\Throwable + */ + public function normalize(mixed $value) : string|int|float|bool|\DateTimeInterface|\Throwable|array + { + if ($value === null) { + return 'null'; + } + + if (\is_scalar($value)) { + return $value; + } + + if ($value instanceof \DateTimeInterface) { + return $value; + } + + if ($value instanceof \Throwable) { + return $value; + } + + if (\is_array($value)) { + /** @var array $result */ + $result = \array_map(fn ($v) => $this->normalize($v), $value); + + return $result; + } + + if (\is_object($value)) { + if (\method_exists($value, '__toString')) { + return (string) $value; + } + + return $value::class; + } + + return \get_debug_type($value); + } +} diff --git a/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Integration/TelemetryLoggerIntegrationTest.php b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Integration/TelemetryLoggerIntegrationTest.php new file mode 100644 index 0000000000..ed102b3b67 --- /dev/null +++ b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Integration/TelemetryLoggerIntegrationTest.php @@ -0,0 +1,111 @@ +logger); + + $psrLogger->debug('debug message'); + $psrLogger->info('info message'); + $psrLogger->notice('notice message'); + $psrLogger->warning('warning message'); + $psrLogger->error('error message'); + $psrLogger->critical('critical message'); + $psrLogger->alert('alert message'); + $psrLogger->emergency('emergency message'); + + $entries = $context->processor->entries(); + self::assertCount(8, $entries); + + self::assertSame(Severity::DEBUG, $entries[0]->record->severity); + self::assertSame(Severity::INFO, $entries[1]->record->severity); + self::assertSame(Severity::INFO, $entries[2]->record->severity); + self::assertSame(Severity::WARN, $entries[3]->record->severity); + self::assertSame(Severity::ERROR, $entries[4]->record->severity); + self::assertSame(Severity::FATAL, $entries[5]->record->severity); + self::assertSame(Severity::FATAL, $entries[6]->record->severity); + self::assertSame(Severity::FATAL, $entries[7]->record->severity); + } + + public function test_custom_converter_remaps_severities_through_pipeline() : void + { + $context = TelemetryTestContext::create(); + + $converter = psr3_log_record_converter( + severityMapper: psr3_severity_mapper([ + LogLevel::DEBUG => Severity::TRACE, + LogLevel::INFO => Severity::INFO, + LogLevel::NOTICE => Severity::WARN, + LogLevel::WARNING => Severity::WARN, + LogLevel::ERROR => Severity::ERROR, + LogLevel::CRITICAL => Severity::FATAL, + LogLevel::ALERT => Severity::FATAL, + LogLevel::EMERGENCY => Severity::FATAL, + ]), + ); + + $psrLogger = psr3_telemetry_logger($context->logger, $converter); + + $psrLogger->debug('mapped to TRACE'); + $psrLogger->notice('mapped to WARN'); + + $entries = $context->processor->entries(); + self::assertCount(2, $entries); + self::assertSame(Severity::TRACE, $entries[0]->record->severity); + self::assertSame(Severity::WARN, $entries[1]->record->severity); + } + + public function test_exception_in_context_populates_exception_attributes() : void + { + $context = TelemetryTestContext::create(); + $psrLogger = psr3_telemetry_logger($context->logger); + + $psrLogger->error('Something went wrong', [ + 'exception' => new \RuntimeException('boom'), + 'request_id' => 'req-1', + ]); + + $entry = $context->processor->entries()[0]; + self::assertSame(\RuntimeException::class, $entry->record->attributes->get('exception.type')); + self::assertSame('boom', $entry->record->attributes->get('exception.message')); + self::assertNotNull($entry->record->attributes->get('exception.stacktrace')); + self::assertSame('req-1', $entry->record->attributes->get('request_id')); + self::assertFalse($entry->record->attributes->has('exception')); + } + + public function test_implements_psr3_logger_interface() : void + { + $context = TelemetryTestContext::create(); + + self::assertInstanceOf(LoggerInterface::class, psr3_telemetry_logger($context->logger)); + } + + public function test_message_interpolation_and_attribute_storage_combined() : void + { + $context = TelemetryTestContext::create(); + $psrLogger = psr3_telemetry_logger($context->logger); + + $psrLogger->info('User {user_id} performed {action} from {ip}', [ + 'user_id' => 42, + 'action' => 'login', + 'ip' => '192.168.1.1', + ]); + + $entry = $context->processor->entries()[0]; + self::assertSame('User 42 performed login from 192.168.1.1', $entry->record->body); + self::assertSame(42, $entry->record->attributes->get('user_id')); + self::assertSame('login', $entry->record->attributes->get('action')); + self::assertSame('192.168.1.1', $entry->record->attributes->get('ip')); + } +} diff --git a/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Integration/TelemetryTestContext.php b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Integration/TelemetryTestContext.php new file mode 100644 index 0000000000..da9a9f52d0 --- /dev/null +++ b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Integration/TelemetryTestContext.php @@ -0,0 +1,41 @@ +logger( + $resource ?? Resource::create(['service.name' => 'psr3-test-service']), + $scope, + $version, + ); + + return new self($processor, $logger); + } +} diff --git a/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/LogRecordConverterTest.php b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/LogRecordConverterTest.php new file mode 100644 index 0000000000..a79daa4776 --- /dev/null +++ b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/LogRecordConverterTest.php @@ -0,0 +1,159 @@ +convert(LogLevel::INFO, $message); + + self::assertSame('rendered body', $record->body); + } + + public function test_context_entries_become_attributes_with_raw_keys() : void + { + $record = (new LogRecordConverter())->convert(LogLevel::INFO, 'msg', [ + 'user_id' => 123, + 'role' => 'admin', + ]); + + self::assertSame(123, $record->attributes->get('user_id')); + self::assertSame('admin', $record->attributes->get('role')); + } + + public function test_default_normalizer_handles_null_in_attributes() : void + { + $record = (new LogRecordConverter(valueNormalizer: new ValueNormalizer())) + ->convert(LogLevel::INFO, 'msg', ['nullable' => null]); + + self::assertSame('null', $record->attributes->get('nullable')); + } + + public function test_exception_in_context_is_routed_via_set_exception() : void + { + $exception = new \RuntimeException('boom'); + + $record = (new LogRecordConverter())->convert(LogLevel::ERROR, 'failure', [ + 'exception' => $exception, + ]); + + self::assertSame(\RuntimeException::class, $record->attributes->get('exception.type')); + self::assertSame('boom', $record->attributes->get('exception.message')); + self::assertNotNull($record->attributes->get('exception.stacktrace')); + self::assertFalse($record->attributes->has('exception')); + } + + public function test_interpolates_message_placeholders_from_context() : void + { + $record = (new LogRecordConverter())->convert(LogLevel::INFO, 'User {user_id} performed {action}', [ + 'user_id' => 123, + 'action' => 'login', + ]); + + self::assertSame('User 123 performed login', $record->body); + self::assertSame(123, $record->attributes->get('user_id')); + self::assertSame('login', $record->attributes->get('action')); + } + + public function test_interpolation_skips_arrays_and_throwables_and_plain_objects() : void + { + $record = (new LogRecordConverter())->convert( + LogLevel::INFO, + 'Tags {tags} error {exception} obj {obj}', + [ + 'tags' => ['a', 'b'], + 'exception' => new \RuntimeException('x'), + 'obj' => new \stdClass(), + ], + ); + + self::assertSame('Tags {tags} error {exception} obj {obj}', $record->body); + } + + public function test_interpolation_uses_stringable_objects() : void + { + $stringable = new class implements \Stringable { + public function __toString() : string + { + return 'CTX'; + } + }; + + $record = (new LogRecordConverter())->convert(LogLevel::INFO, 'value={item}', [ + 'item' => $stringable, + ]); + + self::assertSame('value=CTX', $record->body); + } + + public function test_message_without_braces_is_returned_verbatim() : void + { + $record = (new LogRecordConverter())->convert(LogLevel::INFO, 'no placeholders here', [ + 'user_id' => 5, + ]); + + self::assertSame('no placeholders here', $record->body); + } + + public function test_non_throwable_under_exception_key_is_stored_as_attribute() : void + { + $record = (new LogRecordConverter())->convert(LogLevel::ERROR, 'msg', [ + 'exception' => 'not-a-throwable', + ]); + + self::assertSame('not-a-throwable', $record->attributes->get('exception')); + self::assertFalse($record->attributes->has('exception.type')); + } + + public function test_normalizes_arbitrary_php_values_in_context() : void + { + $record = (new LogRecordConverter())->convert(LogLevel::INFO, 'msg', [ + 'nullable' => null, + 'object' => new \stdClass(), + ]); + + self::assertSame('null', $record->attributes->get('nullable')); + self::assertSame(\stdClass::class, $record->attributes->get('object')); + } + + public function test_severity_mapping_uses_provided_mapper() : void + { + $mapper = new SeverityMapper([ + LogLevel::DEBUG => Severity::TRACE, + LogLevel::INFO => Severity::INFO, + LogLevel::NOTICE => Severity::INFO, + LogLevel::WARNING => Severity::WARN, + LogLevel::ERROR => Severity::ERROR, + LogLevel::CRITICAL => Severity::FATAL, + LogLevel::ALERT => Severity::FATAL, + LogLevel::EMERGENCY => Severity::FATAL, + ]); + + $record = (new LogRecordConverter($mapper))->convert(LogLevel::DEBUG, 'msg'); + + self::assertSame(Severity::TRACE, $record->severity); + } + + public function test_throws_on_unknown_level() : void + { + $this->expectException(InvalidArgumentException::class); + + (new LogRecordConverter())->convert('verbose', 'msg'); + } +} diff --git a/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/SeverityMapperTest.php b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/SeverityMapperTest.php new file mode 100644 index 0000000000..fe32f84c4f --- /dev/null +++ b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/SeverityMapperTest.php @@ -0,0 +1,105 @@ + + */ + public static function default_mapping_provider() : \Generator + { + yield 'debug' => [LogLevel::DEBUG, Severity::DEBUG]; + yield 'info' => [LogLevel::INFO, Severity::INFO]; + yield 'notice' => [LogLevel::NOTICE, Severity::INFO]; + yield 'warning' => [LogLevel::WARNING, Severity::WARN]; + yield 'error' => [LogLevel::ERROR, Severity::ERROR]; + yield 'critical' => [LogLevel::CRITICAL, Severity::FATAL]; + yield 'alert' => [LogLevel::ALERT, Severity::FATAL]; + yield 'emergency' => [LogLevel::EMERGENCY, Severity::FATAL]; + } + + public function test_accepts_stringable_level() : void + { + $mapper = new SeverityMapper(); + $level = new class implements \Stringable { + public function __toString() : string + { + return LogLevel::WARNING; + } + }; + + self::assertSame(Severity::WARN, $mapper->map($level)); + } + + public function test_custom_mapping_overrides_defaults() : void + { + $mapper = new SeverityMapper([ + LogLevel::DEBUG => Severity::TRACE, + LogLevel::INFO => Severity::INFO, + LogLevel::NOTICE => Severity::WARN, + LogLevel::WARNING => Severity::WARN, + LogLevel::ERROR => Severity::ERROR, + LogLevel::CRITICAL => Severity::FATAL, + LogLevel::ALERT => Severity::FATAL, + LogLevel::EMERGENCY => Severity::FATAL, + ]); + + self::assertSame(Severity::TRACE, $mapper->map(LogLevel::DEBUG)); + self::assertSame(Severity::WARN, $mapper->map(LogLevel::NOTICE)); + } + + public function test_default_mapping_exposes_full_psr3_level_set() : void + { + $mapping = SeverityMapper::defaultMapping(); + + self::assertSame([ + LogLevel::DEBUG, + LogLevel::INFO, + LogLevel::NOTICE, + LogLevel::WARNING, + LogLevel::ERROR, + LogLevel::CRITICAL, + LogLevel::ALERT, + LogLevel::EMERGENCY, + ], array_keys($mapping)); + } + + #[DataProvider('default_mapping_provider')] + public function test_default_mapping_matches_psr3_to_otel_severity(string $level, Severity $expected) : void + { + $mapper = new SeverityMapper(); + + self::assertSame($expected, $mapper->map($level)); + } + + public function test_throws_on_unknown_level() : void + { + $mapper = new SeverityMapper(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown PSR-3 log level: verbose'); + + $mapper->map('verbose'); + } + + public function test_throws_when_custom_mapping_lacks_level() : void + { + $mapper = new SeverityMapper([ + LogLevel::ERROR => Severity::ERROR, + ]); + + $this->expectException(InvalidArgumentException::class); + + $mapper->map(LogLevel::DEBUG); + } +} diff --git a/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/TelemetryLoggerTest.php b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/TelemetryLoggerTest.php new file mode 100644 index 0000000000..73d9574c5e --- /dev/null +++ b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/TelemetryLoggerTest.php @@ -0,0 +1,208 @@ + + */ + public static function level_to_severity_provider() : \Generator + { + yield 'debug' => [LogLevel::DEBUG, Severity::DEBUG]; + yield 'info' => [LogLevel::INFO, Severity::INFO]; + yield 'notice' => [LogLevel::NOTICE, Severity::INFO]; + yield 'warning' => [LogLevel::WARNING, Severity::WARN]; + yield 'error' => [LogLevel::ERROR, Severity::ERROR]; + yield 'critical' => [LogLevel::CRITICAL, Severity::FATAL]; + yield 'alert' => [LogLevel::ALERT, Severity::FATAL]; + yield 'emergency' => [LogLevel::EMERGENCY, Severity::FATAL]; + } + + protected function setUp() : void + { + $this->processor = new MemoryLogProcessor(new VoidLogExporter()); + + $logger = (new LoggerProvider( + $this->processor, + new SystemClock(), + new MemoryContextStorage(), + ))->logger(Resource::empty(), 'psr3-test-scope'); + + $this->psrLogger = new TelemetryLogger($logger); + } + + public function test_alert_emits_fatal_severity() : void + { + $this->psrLogger->alert('alarm'); + + self::assertSame(Severity::FATAL, $this->processor->entries()[0]->record->severity); + } + + public function test_critical_emits_fatal_severity() : void + { + $this->psrLogger->critical('crit'); + + self::assertSame(Severity::FATAL, $this->processor->entries()[0]->record->severity); + } + + public function test_custom_converter_is_used() : void + { + $processor = new MemoryLogProcessor(new VoidLogExporter()); + $logger = (new LoggerProvider( + $processor, + new SystemClock(), + new MemoryContextStorage(), + ))->logger(Resource::empty(), 'psr3-custom-scope'); + + $converter = new LogRecordConverter(new SeverityMapper([ + LogLevel::DEBUG => Severity::TRACE, + LogLevel::INFO => Severity::INFO, + LogLevel::NOTICE => Severity::INFO, + LogLevel::WARNING => Severity::WARN, + LogLevel::ERROR => Severity::ERROR, + LogLevel::CRITICAL => Severity::FATAL, + LogLevel::ALERT => Severity::FATAL, + LogLevel::EMERGENCY => Severity::FATAL, + ])); + + $psrLogger = new TelemetryLogger($logger, $converter); + $psrLogger->debug('trace-me'); + + self::assertSame(Severity::TRACE, $processor->entries()[0]->record->severity); + } + + public function test_debug_emits_debug_severity() : void + { + $this->psrLogger->debug('hello'); + + self::assertSame(Severity::DEBUG, $this->processor->entries()[0]->record->severity); + } + + public function test_emergency_emits_fatal_severity() : void + { + $this->psrLogger->emergency('panic'); + + self::assertSame(Severity::FATAL, $this->processor->entries()[0]->record->severity); + } + + public function test_error_emits_error_severity() : void + { + $this->psrLogger->error('oops'); + + self::assertSame(Severity::ERROR, $this->processor->entries()[0]->record->severity); + } + + public function test_exception_in_context_routes_to_set_exception() : void + { + $exception = new \RuntimeException('boom'); + + $this->psrLogger->error('failure', ['exception' => $exception]); + + $entry = $this->processor->entries()[0]; + self::assertSame(\RuntimeException::class, $entry->record->attributes->get('exception.type')); + self::assertSame('boom', $entry->record->attributes->get('exception.message')); + self::assertNotNull($entry->record->attributes->get('exception.stacktrace')); + self::assertFalse($entry->record->attributes->has('exception')); + } + + public function test_forwards_message_body_verbatim_when_no_placeholders() : void + { + $this->psrLogger->info('Hello World'); + + self::assertSame('Hello World', $this->processor->entries()[0]->record->body); + } + + public function test_info_emits_info_severity() : void + { + $this->psrLogger->info('hello'); + + self::assertSame(Severity::INFO, $this->processor->entries()[0]->record->severity); + } + + public function test_interpolates_placeholders_from_context() : void + { + $this->psrLogger->info('User {user_id} logged in', ['user_id' => 123]); + + $entry = $this->processor->entries()[0]; + self::assertSame('User 123 logged in', $entry->record->body); + self::assertSame(123, $entry->record->attributes->get('user_id')); + } + + #[DataProvider('level_to_severity_provider')] + public function test_log_maps_level_string_to_severity(string $level, Severity $expected) : void + { + $this->psrLogger->log($level, 'msg'); + + self::assertSame($expected, $this->processor->entries()[0]->record->severity); + } + + public function test_log_throws_on_non_string_level() : void + { + $this->expectException(InvalidArgumentException::class); + + $this->psrLogger->log(42, 'msg'); + } + + public function test_log_throws_on_unknown_level() : void + { + $this->expectException(InvalidArgumentException::class); + + $this->psrLogger->log('verbose', 'msg'); + } + + public function test_notice_emits_info_severity() : void + { + $this->psrLogger->notice('note'); + + self::assertSame(Severity::INFO, $this->processor->entries()[0]->record->severity); + } + + public function test_stores_context_under_raw_keys() : void + { + $this->psrLogger->info('msg', ['user_id' => 1, 'role' => 'admin']); + + $entry = $this->processor->entries()[0]; + self::assertSame(1, $entry->record->attributes->get('user_id')); + self::assertSame('admin', $entry->record->attributes->get('role')); + } + + public function test_stringable_message_is_supported() : void + { + $message = new class implements \Stringable { + public function __toString() : string + { + return 'rendered'; + } + }; + + $this->psrLogger->info($message); + + self::assertSame('rendered', $this->processor->entries()[0]->record->body); + } + + public function test_warning_emits_warn_severity() : void + { + $this->psrLogger->warning('careful'); + + self::assertSame(Severity::WARN, $this->processor->entries()[0]->record->severity); + } +} diff --git a/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/ValueNormalizerTest.php b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/ValueNormalizerTest.php new file mode 100644 index 0000000000..67985cd30d --- /dev/null +++ b/src/bridge/psr3/telemetry/tests/Flow/Bridge/Psr3/Telemetry/Tests/Unit/ValueNormalizerTest.php @@ -0,0 +1,80 @@ + 1, 'b' => ['c' => 'two']], + $normalizer->normalize(['a' => 1, 'b' => ['c' => 'two']]), + ); + } + + public function test_normalizes_datetime_passthrough() : void + { + $normalizer = new ValueNormalizer(); + $datetime = new \DateTimeImmutable('2024-01-15 10:30:00'); + + self::assertSame($datetime, $normalizer->normalize($datetime)); + } + + public function test_normalizes_null_to_string() : void + { + self::assertSame('null', (new ValueNormalizer())->normalize(null)); + } + + public function test_normalizes_object_with_to_string_to_string() : void + { + $object = new class implements \Stringable { + public function __toString() : string + { + return 'rendered'; + } + }; + + self::assertSame('rendered', (new ValueNormalizer())->normalize($object)); + } + + public function test_normalizes_object_without_to_string_to_class_name() : void + { + self::assertSame(\stdClass::class, (new ValueNormalizer())->normalize(new \stdClass())); + } + + public function test_normalizes_resource_to_debug_type() : void + { + $resource = fopen('php://memory', 'rb'); + self::assertNotFalse($resource); + + try { + self::assertSame('resource (stream)', (new ValueNormalizer())->normalize($resource)); + } finally { + fclose($resource); + } + } + + public function test_normalizes_scalar_passthrough() : void + { + $normalizer = new ValueNormalizer(); + + self::assertSame('text', $normalizer->normalize('text')); + self::assertSame(42, $normalizer->normalize(42)); + self::assertSame(3.14, $normalizer->normalize(3.14)); + self::assertTrue($normalizer->normalize(true)); + } + + public function test_normalizes_throwable_passthrough() : void + { + $exception = new \RuntimeException('boom'); + + self::assertSame($exception, (new ValueNormalizer())->normalize($exception)); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/composer.json b/src/bridge/symfony/telemetry-bundle/composer.json index 83e60f146b..f0460f50bc 100644 --- a/src/bridge/symfony/telemetry-bundle/composer.json +++ b/src/bridge/symfony/telemetry-bundle/composer.json @@ -16,8 +16,9 @@ "license": "MIT", "require": { "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "flow-php/telemetry": "self.version", + "flow-php/psr3-telemetry-bridge": "self.version", "flow-php/symfony-http-foundation-telemetry-bridge": "self.version", + "flow-php/telemetry": "self.version", "psr/clock": "^1.0", "symfony/config": "^6.4 || ^7.4 || ^8.0", "symfony/console": "^6.4 || ^7.4 || ^8.0", diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/FrameworkLoggerPass.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/FrameworkLoggerPass.php new file mode 100644 index 0000000000..3154794c61 --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Compiler/FrameworkLoggerPass.php @@ -0,0 +1,55 @@ +hasParameter('flow.telemetry.framework_logger') + ? $container->getParameter('flow.telemetry.framework_logger') + : null; + + if ($frameworkLogger !== null) { + if (!\is_string($frameworkLogger) || $frameworkLogger === '') { + throw new RuntimeException('flow_telemetry.framework_logger must be a non-empty string referencing a configured logger name.'); + } + + $targetId = 'flow.telemetry.' . $frameworkLogger . '.logger.psr3'; + + if (!$container->hasDefinition($targetId) && !$container->hasAlias($targetId)) { + throw new RuntimeException(\sprintf( + 'Configured framework_logger "%s" does not have a registered PSR-3 wrapper service "%s". Make sure a logger with that name is configured under flow_telemetry.loggers, or use the always-available "default".', + $frameworkLogger, + $targetId, + )); + } + + $container->setAlias('logger', $targetId)->setPublic(true); + + return; + } + + if ($container->hasAlias('logger')) { + return; + } + + if (!$container->hasDefinition('logger')) { + return; + } + + if ($container->getDefinition('logger')->getClass() !== self::SYMFONY_DEFAULT_LOGGER) { + return; + } + + $container->setAlias('logger', 'flow.telemetry.default.logger.psr3')->setPublic(true); + } +} diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Configuration.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Configuration.php index a4f6621b36..3d62bc60a5 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Configuration.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/Configuration.php @@ -34,7 +34,7 @@ public function getConfigTreeBuilder() : TreeBuilder ->addDefaultsIfNotSet() ->children() ->arrayNode('cache') - ->info('Caching configuration for static resource attributes') + ->info('File-based caching of static resource attributes. The cache file is intentionally outside Symfony\'s cache lifecycle so build-time cache:warmup does not freeze runtime-dependent attributes (host, process).') ->addDefaultsIfNotSet() ->children() ->booleanNode('enabled') @@ -42,7 +42,7 @@ public function getConfigTreeBuilder() : TreeBuilder ->defaultTrue() ->end() ->scalarNode('path') - ->info('Cache file path (default: kernel cache dir)') + ->info('Absolute path to the cache file. Default: sys_get_temp_dir()/flow_telemetry_resource.cache.') ->defaultNull() ->end() ->end() @@ -111,6 +111,10 @@ public function getConfigTreeBuilder() : TreeBuilder ->info('Custom PSR-20 clock service ID. If not provided, uses built-in SystemClock.') ->defaultNull() ->end() + ->scalarNode('framework_logger') + ->info('Name of the logger (matching a key under "loggers", or "default") whose PSR-3 wrapper will be aliased to Symfony\'s "logger" service. Leave null to auto-replace only when Symfony\'s default HttpKernel Logger is currently bound.') + ->defaultNull() + ->end() ->arrayNode('context_storage') ->info('Context storage configuration') ->addDefaultsIfNotSet() diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/FlowTelemetryExtension.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/FlowTelemetryExtension.php index 0aa6f83ea3..ab76035e65 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/FlowTelemetryExtension.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/DependencyInjection/FlowTelemetryExtension.php @@ -4,6 +4,7 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection; +use Flow\Bridge\Psr3\Telemetry\{LogRecordConverter, TelemetryLogger}; use Flow\Bridge\Symfony\TelemetryBundle\Exception\RuntimeException; use Flow\Bridge\Symfony\TelemetryBundle\Resource\Detector\SymfonyDeploymentDetector; use Flow\Bridge\Telemetry\OTLP\Exporter\{OTLPLogExporter, OTLPMetricExporter, OTLPSpanExporter}; @@ -66,17 +67,23 @@ public function load(array $configs, ContainerBuilder $container) : void { $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); $configuration = new Configuration(); - /** @var array{resource: array{detectors?: array{enabled?: bool, static?: array{cache?: array{enabled?: bool, path?: null|string}, os?: array{enabled?: bool}, host?: array{enabled?: bool}, service?: array{enabled?: bool}, deployment?: array{enabled?: bool}, environment?: array{enabled?: bool}}, dynamic?: array{process?: array{enabled?: bool}}}, custom?: array}, clock_service_id?: null|string, context_storage?: array{type?: string, service_id?: null|string}, propagator?: array{type?: string, service_id?: null|string}, tracer_provider?: array, meter_provider?: array, logger_provider?: array, instrumentation?: array{http_kernel?: array{enabled?: bool, exclude_paths?: array, context_propagation?: bool}, console?: array{enabled?: bool, exclude_commands?: array}, messenger?: array{enabled?: bool, context_propagation?: bool}, twig?: array{enabled?: bool, trace_templates?: bool, trace_blocks?: bool, trace_macros?: bool, exclude_templates?: array}, http_client?: array{enabled?: bool, exclude_clients?: array}, psr18_client?: array{enabled?: bool, exclude_clients?: array}, dbal?: array{enabled?: bool, log_sql?: bool, max_sql_length?: int, exclude_connections?: array}, cache?: array{enabled?: bool, exclude_pools?: array}}, tracers?: array}>, meters?: array}>, loggers?: array}>} $config */ + /** @var array{resource: array{detectors?: array{enabled?: bool, static?: array{cache?: array{enabled?: bool, path?: null|string}, os?: array{enabled?: bool}, host?: array{enabled?: bool}, service?: array{enabled?: bool}, deployment?: array{enabled?: bool}, environment?: array{enabled?: bool}}, dynamic?: array{process?: array{enabled?: bool}}}, custom?: array}, clock_service_id?: null|string, framework_logger?: null|string, context_storage?: array{type?: string, service_id?: null|string}, propagator?: array{type?: string, service_id?: null|string}, tracer_provider?: array, meter_provider?: array, logger_provider?: array, instrumentation?: array{http_kernel?: array{enabled?: bool, exclude_paths?: array, context_propagation?: bool}, console?: array{enabled?: bool, exclude_commands?: array}, messenger?: array{enabled?: bool, context_propagation?: bool}, twig?: array{enabled?: bool, trace_templates?: bool, trace_blocks?: bool, trace_macros?: bool, exclude_templates?: array}, http_client?: array{enabled?: bool, exclude_clients?: array}, psr18_client?: array{enabled?: bool, exclude_clients?: array}, dbal?: array{enabled?: bool, log_sql?: bool, max_sql_length?: int, exclude_connections?: array}, cache?: array{enabled?: bool, exclude_pools?: array}}, tracers?: array}>, meters?: array}>, loggers?: array}>} $config */ $config = $this->processConfiguration($configuration, $configs); + $container->setParameter('flow.telemetry.framework_logger', $config['framework_logger'] ?? null); + + $tracers = ($config['tracers'] ?? []) + ['default' => []]; + $meters = ($config['meters'] ?? []) + ['default' => []]; + $loggers = ($config['loggers'] ?? []) + ['default' => []]; + $this->registerGlobalServices($config, $container); $this->registerPropagator($config['propagator'] ?? [], $container); $this->registerResource($config['resource'], $container); $this->registerTelemetry($config, $container); $this->registerInstrumentation($config['instrumentation'] ?? [], $container, $loader); - $this->registerTracers($config['tracers'] ?? [], $container); - $this->registerMeters($config['meters'] ?? [], $container); - $this->registerLoggers($config['loggers'] ?? [], $container); + $this->registerTracers($tracers, $container); + $this->registerMeters($meters, $container); + $this->registerLoggers($loggers, $container); } /** @@ -907,6 +914,11 @@ private function registerGlobalServices(array $config, ContainerBuilder $contain } else { $container->setDefinition('flow.telemetry.context_storage', new Definition(MemoryContextStorage::class)); } + + $container->setDefinition( + 'flow.telemetry.psr3.log_record_converter', + new Definition(LogRecordConverter::class) + ); } /** @@ -986,7 +998,14 @@ private function registerLoggers(array $config, ContainerBuilder $container) : v } $definition->setPublic(true); - $container->setDefinition('flow.telemetry.' . $name . '.logger', $definition); + $loggerServiceId = 'flow.telemetry.' . $name . '.logger'; + $container->setDefinition($loggerServiceId, $definition); + + $psr3Definition = new Definition(TelemetryLogger::class); + $psr3Definition->setArgument(0, new Reference($loggerServiceId)); + $psr3Definition->setArgument(1, new Reference('flow.telemetry.psr3.log_record_converter')); + $psr3Definition->setPublic(true); + $container->setDefinition($loggerServiceId . '.psr3', $psr3Definition); } } @@ -1195,10 +1214,9 @@ private function registerResource(array $resourceConfig, ContainerBuilder $conta $cacheEnabled = $cacheConfig['enabled'] ?? true; if ($cacheEnabled) { - $cachePath = $cacheConfig['path'] ?? '%kernel.cache_dir%/flow_telemetry_resource.cache'; $cachingDefinition = new Definition(CachingDetector::class); $cachingDefinition->setArgument(0, new Reference('flow.telemetry.resource.detector.static.chain')); - $cachingDefinition->setArgument(1, $cachePath); + $cachingDefinition->setArgument(1, $cacheConfig['path'] ?? null); $container->setDefinition('flow.telemetry.resource.detector.static', $cachingDefinition); } else { $container->setAlias('flow.telemetry.resource.detector.static', 'flow.telemetry.resource.detector.static.chain'); diff --git a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php index 0da0ebd74e..4353de75f4 100644 --- a/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php +++ b/src/bridge/symfony/telemetry-bundle/src/Flow/Bridge/Symfony/TelemetryBundle/FlowTelemetryBundle.php @@ -4,7 +4,8 @@ namespace Flow\Bridge\Symfony\TelemetryBundle; -use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\{CacheTelemetryPass, DBALTelemetryPass, HttpClientTelemetryPass, OTLPAvailabilityPass, Psr18ClientTelemetryPass}; +use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\{CacheTelemetryPass, DBALTelemetryPass, FrameworkLoggerPass, HttpClientTelemetryPass, OTLPAvailabilityPass, Psr18ClientTelemetryPass}; +use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -25,6 +26,7 @@ public function build(ContainerBuilder $container) : void parent::build($container); $container->addCompilerPass(new OTLPAvailabilityPass()); + $container->addCompilerPass(new FrameworkLoggerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -64); if (\interface_exists(self::HTTP_CLIENT_INTERFACE)) { $container->addCompilerPass(new HttpClientTelemetryPass()); diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Context/SymfonyContext.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Context/SymfonyContext.php index 005613eb73..a33e59759c 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Context/SymfonyContext.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Context/SymfonyContext.php @@ -103,5 +103,11 @@ public function shutdown() : void if ($filesystem->exists($logDir)) { $filesystem->remove($logDir); } + + $defaultResourceCache = \sys_get_temp_dir() . '/flow_telemetry_resource.cache'; + + if ($filesystem->exists($defaultResourceCache)) { + $filesystem->remove($defaultResourceCache); + } } } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Logger/StubLogger.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Logger/StubLogger.php new file mode 100644 index 0000000000..19448f29dd --- /dev/null +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/Logger/StubLogger.php @@ -0,0 +1,25 @@ +}> */ + public array $records = []; + + /** + * @param array $context + */ + public function log($level, string|\Stringable $message, array $context = []) : void + { + $this->records[] = [ + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + } +} diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/TestKernel.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/TestKernel.php index 7c5dffe077..3539e3c9c2 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/TestKernel.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Fixtures/TestKernel.php @@ -121,7 +121,7 @@ protected function build(ContainerBuilder $container) : void public function process(ContainerBuilder $container) : void { foreach ($container->getDefinitions() as $id => $definition) { - if (\str_starts_with($id, 'flow.telemetry') || \str_ends_with($id, '.flow_telemetry') || \str_starts_with($id, 'test.')) { + if (\str_starts_with($id, 'flow.telemetry') || \str_ends_with($id, '.flow_telemetry') || \str_starts_with($id, 'test.') || \str_starts_with($id, 'cache.flow_telemetry')) { $definition->setPublic(true); } } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php index 59c284704f..773c8b9e01 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Integration/FlowTelemetryExtensionTest.php @@ -4,9 +4,11 @@ namespace Flow\Bridge\Symfony\TelemetryBundle\Tests\Integration; -use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\OTLPAvailabilityPass; +use Flow\Bridge\Psr3\Telemetry\TelemetryLogger; +use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\Compiler\{FrameworkLoggerPass, OTLPAvailabilityPass}; use Flow\Bridge\Symfony\TelemetryBundle\DependencyInjection\FlowTelemetryExtension; use Flow\Bridge\Symfony\TelemetryBundle\Exception\RuntimeException; +use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\Logger\StubLogger; use Flow\Bridge\Symfony\TelemetryBundle\Tests\Fixtures\TestKernel; use Flow\Telemetry\Context\MemoryContextStorage; use Flow\Telemetry\{Logger\Logger, Meter\Meter, Resource, Telemetry, Tracer\Tracer}; @@ -18,16 +20,77 @@ use Flow\Telemetry\Provider\Console\{ConsoleLogExporter, ConsoleMetricExporter, ConsoleSpanExporter}; use Flow\Telemetry\Provider\Memory\{MemoryLogExporter, MemoryLogProcessor, MemoryMetricExporter, MemoryMetricProcessor, MemorySpanExporter, MemorySpanProcessor}; use Flow\Telemetry\Provider\Void\{VoidLogExporter, VoidLogProcessor, VoidMetricExporter, VoidMetricProcessor, VoidSpanExporter, VoidSpanProcessor}; +use Flow\Telemetry\Resource\Detector\CachingDetector; use Flow\Telemetry\Tracer\Processor\{BatchingSpanProcessor, CompositeSpanProcessor, PassThroughSpanProcessor}; use Flow\Telemetry\Tracer\Sampler\{AlwaysOffSampler, AlwaysOnSampler, ParentBasedSampler, TraceIdRatioBasedSampler}; use Flow\Telemetry\Tracer\TracerProvider; use PHPUnit\Framework\Attributes\CoversClass; -use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\{ContainerBuilder, Definition}; +use Symfony\Component\HttpKernel\Log\Logger as SymfonyDefaultLogger; #[CoversClass(FlowTelemetryExtension::class)] #[CoversClass(OTLPAvailabilityPass::class)] +#[CoversClass(FrameworkLoggerPass::class)] final class FlowTelemetryExtensionTest extends KernelTestCase { + public function test_auto_alias_when_logger_service_is_symfony_default() : void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + ]); + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container) : void { + $loggerDefinition = new Definition(SymfonyDefaultLogger::class); + $loggerDefinition->setPublic(true); + $container->setDefinition('logger', $loggerDefinition); + }); + }, + ]); + + $container = $this->getContainer(); + + self::assertSame( + $container->get('flow.telemetry.default.logger.psr3'), + $container->get('logger'), + ); + } + + public function test_caching_detector_writes_to_configured_path() : void + { + $cachePath = \sys_get_temp_dir() . '/flow_telemetry_resource_' . \uniqid() . '.cache'; + + try { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) use ($cachePath) : void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [ + 'detectors' => [ + 'static' => [ + 'cache' => ['path' => $cachePath], + ], + ], + 'custom' => ['service.name' => 'cached-service'], + ], + ]); + }, + ]); + + $container = $this->getContainer(); + $detector = $container->get('flow.telemetry.resource.detector.static'); + self::assertInstanceOf(CachingDetector::class, $detector); + + $resource = $container->get('flow.telemetry.resource'); + self::assertInstanceOf(Resource::class, $resource); + self::assertSame('cached-service', $resource->get('service.name')); + self::assertFileExists($cachePath); + } finally { + if (\is_file($cachePath)) { + \unlink($cachePath); + } + } + } + public function test_composite_log_processor() : void { $this->bootKernel([ @@ -194,6 +257,49 @@ public function test_custom_service_reference_for_sampler() : void ); } + public function test_default_logger_meter_tracer_are_always_registered_when_user_defined_none() : void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + ]); + }, + ]); + + $container = $this->getContainer(); + + self::assertInstanceOf(Logger::class, $container->get('flow.telemetry.default.logger')); + self::assertInstanceOf(Meter::class, $container->get('flow.telemetry.default.meter')); + self::assertInstanceOf(Tracer::class, $container->get('flow.telemetry.default.tracer')); + self::assertInstanceOf(TelemetryLogger::class, $container->get('flow.telemetry.default.logger.psr3')); + } + + public function test_default_named_instances_are_registered_alongside_user_defined_ones() : void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'loggers' => ['app' => []], + 'meters' => ['app' => []], + 'tracers' => ['app' => []], + ]); + }, + ]); + + $container = $this->getContainer(); + + self::assertInstanceOf(Logger::class, $container->get('flow.telemetry.app.logger')); + self::assertInstanceOf(Logger::class, $container->get('flow.telemetry.default.logger')); + self::assertInstanceOf(Meter::class, $container->get('flow.telemetry.app.meter')); + self::assertInstanceOf(Meter::class, $container->get('flow.telemetry.default.meter')); + self::assertInstanceOf(Tracer::class, $container->get('flow.telemetry.app.tracer')); + self::assertInstanceOf(Tracer::class, $container->get('flow.telemetry.default.tracer')); + self::assertInstanceOf(TelemetryLogger::class, $container->get('flow.telemetry.app.logger.psr3')); + self::assertInstanceOf(TelemetryLogger::class, $container->get('flow.telemetry.default.logger.psr3')); + } + public function test_flow_telemetry_is_aliased_to_telemetry_class() : void { $this->bootKernel([ @@ -210,6 +316,39 @@ public function test_flow_telemetry_is_aliased_to_telemetry_class() : void self::assertSame($container->get('flow.telemetry'), $container->get(Telemetry::class)); } + public function test_framework_logger_aliases_symfony_logger_service_to_psr3_wrapper() : void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'loggers' => ['app' => []], + 'framework_logger' => 'app', + ]); + }, + ]); + + $container = $this->getContainer(); + + self::assertTrue($container->has('logger')); + self::assertInstanceOf(TelemetryLogger::class, $container->get('logger')); + } + + public function test_framework_logger_throws_when_referenced_logger_is_not_configured() : void + { + self::expectException(RuntimeException::class); + self::expectExceptionMessage('flow.telemetry.missing.logger.psr3'); + + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'framework_logger' => 'missing', + ]); + }, + ]); + } + public function test_full_configuration_scenario() : void { $this->bootKernel([ @@ -624,6 +763,51 @@ public function test_named_tracer_with_attributes() : void self::assertInstanceOf(Tracer::class, $tracer); } + public function test_no_auto_alias_when_logger_is_already_an_alias_to_another_service() : void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + ]); + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container) : void { + $thirdPartyDefinition = new Definition(StubLogger::class); + $thirdPartyDefinition->setPublic(true); + $container->setDefinition('app.my_logger', $thirdPartyDefinition); + $container->setAlias('logger', 'app.my_logger')->setPublic(true); + }); + }, + ]); + + $container = $this->getContainer(); + + self::assertSame( + $container->get('app.my_logger'), + $container->get('logger'), + ); + self::assertInstanceOf(StubLogger::class, $container->get('logger')); + } + + public function test_no_auto_alias_when_logger_service_class_is_not_symfony_default() : void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + ]); + $kernel->addTestContainerConfigurator(static function (ContainerBuilder $container) : void { + $loggerDefinition = new Definition(StubLogger::class); + $loggerDefinition->setPublic(true); + $container->setDefinition('logger', $loggerDefinition); + }); + }, + ]); + + $container = $this->getContainer(); + + self::assertInstanceOf(StubLogger::class, $container->get('logger')); + } + public function test_otlp_availability_pass_sets_parameter_when_otlp_not_configured() : void { $this->bootKernel([ @@ -637,6 +821,20 @@ public function test_otlp_availability_pass_sets_parameter_when_otlp_not_configu self::assertTrue($this->getContainer()->hasParameter('flow.telemetry.otlp_available')); } + public function test_psr3_wrapper_service_is_a_telemetry_logger() : void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'loggers' => ['app' => []], + ]); + }, + ]); + + self::assertInstanceOf(TelemetryLogger::class, $this->getContainer()->get('flow.telemetry.app.logger.psr3')); + } + public function test_resource_caching_can_be_disabled() : void { $this->bootKernel([ @@ -1016,4 +1214,22 @@ public function test_tracer_provider_with_trace_id_ratio_sampler() : void self::assertInstanceOf(TraceIdRatioBasedSampler::class, $this->getContainer()->get('flow.telemetry.tracer_provider.sampler')); } + + public function test_user_defined_default_logger_config_overrides_auto_default() : void + { + $this->bootKernel([ + 'config' => static function (TestKernel $kernel) : void { + $kernel->addTestExtensionConfig('flow_telemetry', [ + 'resource' => [], + 'loggers' => [ + 'default' => ['version' => '2.0.0'], + ], + ]); + }, + ]); + + $telemetry = $this->getContainer()->get('flow.telemetry'); + self::assertInstanceOf(Telemetry::class, $telemetry); + self::assertInstanceOf(Logger::class, $telemetry->logger('default', '2.0.0')); + } } diff --git a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php index 375ccca592..ee28007876 100644 --- a/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/src/bridge/symfony/telemetry-bundle/tests/Flow/Bridge/Symfony/TelemetryBundle/Tests/Unit/DependencyInjection/ConfigurationTest.php @@ -895,7 +895,7 @@ public function test_resource_detectors_cache_can_be_configured() : void 'static' => [ 'cache' => [ 'enabled' => false, - 'path' => '/custom/cache/path', + 'path' => '/var/cache/flow_telemetry_resource.cache', ], ], ], @@ -903,7 +903,7 @@ public function test_resource_detectors_cache_can_be_configured() : void ]]); self::assertFalse($config['resource']['detectors']['static']['cache']['enabled']); - self::assertSame('/custom/cache/path', $config['resource']['detectors']['static']['cache']['path']); + self::assertSame('/var/cache/flow_telemetry_resource.cache', $config['resource']['detectors']['static']['cache']['path']); } public function test_resource_detectors_can_be_disabled() : void diff --git a/src/core/etl/src/Flow/ETL/Attribute/Module.php b/src/core/etl/src/Flow/ETL/Attribute/Module.php index da51219984..d400d627a7 100644 --- a/src/core/etl/src/Flow/ETL/Attribute/Module.php +++ b/src/core/etl/src/Flow/ETL/Attribute/Module.php @@ -28,6 +28,7 @@ enum Module : string case POSTGRESQL_MIGRATIONS = 'POSTGRESQL_MIGRATIONS'; case POSTGRESQL_VALINOR_BRIDGE = 'POSTGRESQL_VALINOR_BRIDGE'; case PSR18_TELEMETRY_BRIDGE = 'PSR18_TELEMETRY_BRIDGE'; + case PSR3_TELEMETRY_BRIDGE = 'PSR3_TELEMETRY_BRIDGE'; case PSR7_TELEMETRY_BRIDGE = 'PSR7_TELEMETRY_BRIDGE'; case S3_FILESYSTEM = 'S3_FILESYSTEM'; case SYMFONY_HTTP_FOUNDATION_TELEMETRY_BRIDGE = 'SYMFONY_HTTP_FOUNDATION_TELEMETRY_BRIDGE'; diff --git a/src/lib/filesystem/src/Flow/Filesystem/Path/UnixPath.php b/src/lib/filesystem/src/Flow/Filesystem/Path/UnixPath.php index ef85d38def..5d46e06353 100644 --- a/src/lib/filesystem/src/Flow/Filesystem/Path/UnixPath.php +++ b/src/lib/filesystem/src/Flow/Filesystem/Path/UnixPath.php @@ -44,8 +44,11 @@ public static function realpath(string $path, array|Options $options = []) : sel return new self(\getcwd() ?: '', $options); } - if (($urlParts = \parse_url($path)) && \array_key_exists('scheme', $urlParts) && $urlParts['scheme'] !== 'file') { - return new self($path, $options); + if (($urlParts = \parse_url($path)) && \array_key_exists('scheme', $urlParts)) { + if ($urlParts['scheme'] !== 'file') { + return new self($path, $options); + } + $path = $urlParts['path'] ?? ''; } $realPath = $path; diff --git a/src/lib/filesystem/src/Flow/Filesystem/Path/WindowsPath.php b/src/lib/filesystem/src/Flow/Filesystem/Path/WindowsPath.php index 7a1c81dd77..4453e53226 100644 --- a/src/lib/filesystem/src/Flow/Filesystem/Path/WindowsPath.php +++ b/src/lib/filesystem/src/Flow/Filesystem/Path/WindowsPath.php @@ -45,8 +45,11 @@ public static function realpath(string $path, array|Options $options = []) : sel return new self(\str_replace('\\', '/', \getcwd() ?: ''), $options); } - if (($urlParts = \parse_url($path)) && \array_key_exists('scheme', $urlParts) && $urlParts['scheme'] !== 'file') { - return new self($path, $options); + if (($urlParts = \parse_url($path)) && \array_key_exists('scheme', $urlParts)) { + if ($urlParts['scheme'] !== 'file') { + return new self($path, $options); + } + $path = $urlParts['path'] ?? ''; } $realPath = \str_replace('\\', '/', $path); diff --git a/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Path/UnixPathTest.php b/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Path/UnixPathTest.php index 3811d80847..ced9776fc1 100644 --- a/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Path/UnixPathTest.php +++ b/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Path/UnixPathTest.php @@ -397,6 +397,23 @@ public function test_realpath_with_current_directory_dots() : void self::assertEquals('/path/to/file.txt', $path->path()); } + public function test_realpath_with_file_scheme_normalizes_dot_segments() : void + { + $path = UnixPath::realpath('file:///a/b/../c/./d.txt'); + + self::assertSame('file', $path->protocol()); + self::assertSame('/a/c/d.txt', $path->path()); + } + + public function test_realpath_with_file_scheme_strips_protocol_prefix() : void + { + $path = UnixPath::realpath('file:///private/tmp/foo.txt'); + + self::assertSame('file', $path->protocol()); + self::assertSame('/private/tmp/foo.txt', $path->path()); + self::assertSame('file://private/tmp/foo.txt', $path->uri()); + } + public function test_realpath_with_non_file_scheme() : void { $path = UnixPath::realpath('s3://bucket/key.txt'); diff --git a/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Path/WindowsPathTest.php b/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Path/WindowsPathTest.php index 80caffca90..7128ad5230 100644 --- a/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Path/WindowsPathTest.php +++ b/src/lib/filesystem/tests/Flow/Filesystem/Tests/Unit/Path/WindowsPathTest.php @@ -433,6 +433,14 @@ public function test_realpath_with_absolute_path() : void self::assertEquals('C:/absolute/path/file.txt', $path->path()); } + public function test_realpath_with_file_scheme_strips_protocol_prefix() : void + { + $path = WindowsPath::realpath('file:///C:/tmp/foo.txt'); + + self::assertSame('file', $path->protocol()); + self::assertSame('C:/tmp/foo.txt', $path->path()); + } + public function test_realpath_with_non_file_scheme() : void { $path = WindowsPath::realpath('s3://bucket/key.txt'); diff --git a/web/landing/src/Flow/Website/Model/Documentation/Module.php b/web/landing/src/Flow/Website/Model/Documentation/Module.php index 2349de6163..15a1c419cc 100644 --- a/web/landing/src/Flow/Website/Model/Documentation/Module.php +++ b/web/landing/src/Flow/Website/Model/Documentation/Module.php @@ -27,6 +27,7 @@ enum Module : string case POSTGRESQL_MIGRATIONS = 'PostgreSQL Migrations'; case POSTGRESQL_VALINOR_BRIDGE = 'PostgreSQL Valinor Bridge'; case PSR18_TELEMETRY_BRIDGE = 'PSR-18 Telemetry Bridge'; + case PSR3_TELEMETRY_BRIDGE = 'PSR-3 Telemetry Bridge'; case PSR7_TELEMETRY_BRIDGE = 'PSR-7 Telemetry Bridge'; case S3_FILESYSTEM = 'S3 Filesystem'; case SYMFONY_HTTP_FOUNDATION_TELEMETRY_BRIDGE = 'Symfony HttpFoundation Telemetry Bridge'; @@ -75,6 +76,7 @@ public function priority() : int self::PSR18_TELEMETRY_BRIDGE => 26, self::POSTGRESQL_MIGRATIONS => 27, self::POSTGRESQL_VALINOR_BRIDGE => 28, + self::PSR3_TELEMETRY_BRIDGE => 29, default => 99, }; } diff --git a/web/landing/templates/documentation/navigation_right.html.twig b/web/landing/templates/documentation/navigation_right.html.twig index 4d1c55ad3f..373e8d366f 100644 --- a/web/landing/templates/documentation/navigation_right.html.twig +++ b/web/landing/templates/documentation/navigation_right.html.twig @@ -175,6 +175,9 @@
  • Filesystem S3
  • +
  • + PSR-3 Telemetry +
  • PSR-7 Telemetry