From 06201ac6b5d8b69b6d16908520f7d003bf0eb683 Mon Sep 17 00:00:00 2001 From: Norbert Orzechowicz Date: Thu, 11 Jun 2026 14:01:59 +0200 Subject: [PATCH] feat(flow-php/phpunit-telemetry-bridge): configurable resource attributes - add `resource_attributes` parameter (env, urldecoded key=value pairs) - merge attributes onto the telemetry resource, overriding defaults - percent-decode `OTEL_RESOURCE_ATTRIBUTES` keys/values in telemetry EnvironmentDetector (OTel spec), dropping backslash escaping --- .../bridges/phpunit-telemetry-bridge.md | 143 +++++++++--------- documentation/components/libs/telemetry.md | 4 + documentation/upgrading.md | 19 ++- .../PHPUnit/Telemetry/Configuration.php | 43 ++++++ .../PHPUnit/Telemetry/TelemetryFactory.php | 3 +- .../Tests/Mother/ConfigurationMother.php | 18 +++ .../Tests/Unit/ConfigurationTest.php | 84 ++++++++++ .../Tests/Unit/TelemetryFactoryTest.php | 11 ++ .../Resource/Detector/EnvironmentDetector.php | 61 ++------ .../Detector/EnvironmentDetectorTest.php | 29 +++- 10 files changed, 286 insertions(+), 129 deletions(-) diff --git a/documentation/components/bridges/phpunit-telemetry-bridge.md b/documentation/components/bridges/phpunit-telemetry-bridge.md index f1a9060d8a..a5a69a8afe 100644 --- a/documentation/components/bridges/phpunit-telemetry-bridge.md +++ b/documentation/components/bridges/phpunit-telemetry-bridge.md @@ -25,6 +25,7 @@ Add the extension to your `phpunit.xml.dist`: + @@ -39,20 +40,21 @@ Add the extension to your `phpunit.xml.dist`: ### Shared -| Parameter | Environment variable | Default | Description | -|------------------------|------------------------------------------|-------------------------|----------------------------------------------------------------| -| `service_name` | `FLOW_PHPUNIT_OTEL_SERVICE_NAME` | `phpunit` | Service name reported in telemetry data | -| `transport` | `FLOW_PHPUNIT_OTEL_TRANSPORT` | `curl` | Transport type: `curl`, `grpc`, or `stream` | -| `endpoint` | `FLOW_PHPUNIT_OTEL_ENDPOINT` | `http://localhost:4318` | OTLP endpoint URL (curl/grpc) or destination path (stream) | -| `headers` | `FLOW_PHPUNIT_OTEL_HEADERS` | — | Additional headers (see [Authentication](#authentication)) | -| `emit_traces` | `FLOW_PHPUNIT_OTEL_EMIT_TRACES` | `true` | Enable/disable trace emission | -| `emit_metrics` | `FLOW_PHPUNIT_OTEL_EMIT_METRICS` | `true` | Enable/disable metric emission | -| `emit_test_spans` | `FLOW_PHPUNIT_OTEL_EMIT_TEST_SPANS` | `true` | Create individual spans for each test | -| `emit_test_case_spans` | `FLOW_PHPUNIT_OTEL_EMIT_TEST_CASE_SPANS` | `true` | Create spans for test case classes | -| `memory_real_usage` | `FLOW_PHPUNIT_OTEL_MEMORY_REAL_USAGE` | `false` | Report total system-allocated memory (`real_usage=true`) instead of emalloc usage for memory metrics/attributes | -| `batch_size` | `FLOW_PHPUNIT_OTEL_BATCH_SIZE` | `512` | Items per batch for span/metric/log batching processors | -| `shutdown_timeout_ms` | `FLOW_PHPUNIT_OTEL_SHUTDOWN_TIMEOUT_MS` | `5000` | Wall-clock budget in ms for draining pending requests at shutdown (curl/grpc) | -| `error_handler` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER` | `error_log` | How telemetry errors are surfaced (see [Error Handlers](#error-handlers)) | +| Parameter | Environment variable | Default | Description | +|------------------------|------------------------------------------|-------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `service_name` | `FLOW_PHPUNIT_OTEL_SERVICE_NAME` | `phpunit` | Service name reported in telemetry data | +| `resource_attributes` | `FLOW_PHPUNIT_OTEL_RESOURCE_ATTRIBUTES` | — | Extra resource attributes as `name=value,name2=value2` (URL-encode commas/spaces). Merged last, so they override detected attributes and may override `service.name` | +| `transport` | `FLOW_PHPUNIT_OTEL_TRANSPORT` | `curl` | Transport type: `curl`, `grpc`, or `stream` | +| `endpoint` | `FLOW_PHPUNIT_OTEL_ENDPOINT` | `http://localhost:4318` | OTLP endpoint URL (curl/grpc) or destination path (stream) | +| `headers` | `FLOW_PHPUNIT_OTEL_HEADERS` | — | Additional headers (see [Authentication](#authentication)) | +| `emit_traces` | `FLOW_PHPUNIT_OTEL_EMIT_TRACES` | `true` | Enable/disable trace emission | +| `emit_metrics` | `FLOW_PHPUNIT_OTEL_EMIT_METRICS` | `true` | Enable/disable metric emission | +| `emit_test_spans` | `FLOW_PHPUNIT_OTEL_EMIT_TEST_SPANS` | `true` | Create individual spans for each test | +| `emit_test_case_spans` | `FLOW_PHPUNIT_OTEL_EMIT_TEST_CASE_SPANS` | `true` | Create spans for test case classes | +| `memory_real_usage` | `FLOW_PHPUNIT_OTEL_MEMORY_REAL_USAGE` | `false` | Report total system-allocated memory (`real_usage=true`) instead of emalloc usage for memory metrics/attributes | +| `batch_size` | `FLOW_PHPUNIT_OTEL_BATCH_SIZE` | `512` | Items per batch for span/metric/log batching processors | +| `shutdown_timeout_ms` | `FLOW_PHPUNIT_OTEL_SHUTDOWN_TIMEOUT_MS` | `5000` | Wall-clock budget in ms for draining pending requests at shutdown (curl/grpc) | +| `error_handler` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER` | `error_log` | How telemetry errors are surfaced (see [Error Handlers](#error-handlers)) | ### Curl transport (`transport=curl`) @@ -60,20 +62,20 @@ Timeouts are in **milliseconds** (defaults assume a local collector on loopback per-request deadline; `shutdown_timeout_ms` (shared, see above) is a wall-clock budget enforced at shutdown only — it lets you keep `timeout_ms` tight for steady-state without freezing graceful exit. -| Parameter | Environment variable | Default | Description | -|----------------------------|----------------------------------------------|---------|--------------------------------------------------------------| -| `curl_timeout_ms` | `FLOW_PHPUNIT_OTEL_CURL_TIMEOUT_MS` | `250` | Total request deadline in milliseconds | -| `curl_connect_timeout_ms` | `FLOW_PHPUNIT_OTEL_CURL_CONNECT_TIMEOUT_MS` | `250` | TCP/TLS connect deadline in milliseconds | -| `curl_compression` | `FLOW_PHPUNIT_OTEL_CURL_COMPRESSION` | `false` | Enable automatic response decompression | -| `curl_follow_redirects` | `FLOW_PHPUNIT_OTEL_CURL_FOLLOW_REDIRECTS` | `true` | Follow HTTP redirects | -| `curl_max_redirects` | `FLOW_PHPUNIT_OTEL_CURL_MAX_REDIRECTS` | `3` | Maximum number of redirects to follow | -| `curl_proxy` | `FLOW_PHPUNIT_OTEL_CURL_PROXY` | — | Proxy server URL (e.g. `http://proxy:8080`) | -| `curl_ssl_verify_peer` | `FLOW_PHPUNIT_OTEL_CURL_SSL_VERIFY_PEER` | `true` | Verify SSL peer certificate | -| `curl_ssl_verify_host` | `FLOW_PHPUNIT_OTEL_CURL_SSL_VERIFY_HOST` | `true` | Verify SSL host name | -| `curl_ssl_cert_path` | `FLOW_PHPUNIT_OTEL_CURL_SSL_CERT_PATH` | — | Path to client SSL certificate | -| `curl_ssl_key_path` | `FLOW_PHPUNIT_OTEL_CURL_SSL_KEY_PATH` | — | Path to client SSL private key | -| `curl_ca_info_path` | `FLOW_PHPUNIT_OTEL_CURL_CA_INFO_PATH` | — | Path to CA certificate bundle | -| `curl_serializer` | `FLOW_PHPUNIT_OTEL_CURL_SERIALIZER` | `json` | Payload serializer: `json` or `protobuf` | +| Parameter | Environment variable | Default | Description | +|---------------------------|---------------------------------------------|---------|---------------------------------------------| +| `curl_timeout_ms` | `FLOW_PHPUNIT_OTEL_CURL_TIMEOUT_MS` | `250` | Total request deadline in milliseconds | +| `curl_connect_timeout_ms` | `FLOW_PHPUNIT_OTEL_CURL_CONNECT_TIMEOUT_MS` | `250` | TCP/TLS connect deadline in milliseconds | +| `curl_compression` | `FLOW_PHPUNIT_OTEL_CURL_COMPRESSION` | `false` | Enable automatic response decompression | +| `curl_follow_redirects` | `FLOW_PHPUNIT_OTEL_CURL_FOLLOW_REDIRECTS` | `true` | Follow HTTP redirects | +| `curl_max_redirects` | `FLOW_PHPUNIT_OTEL_CURL_MAX_REDIRECTS` | `3` | Maximum number of redirects to follow | +| `curl_proxy` | `FLOW_PHPUNIT_OTEL_CURL_PROXY` | — | Proxy server URL (e.g. `http://proxy:8080`) | +| `curl_ssl_verify_peer` | `FLOW_PHPUNIT_OTEL_CURL_SSL_VERIFY_PEER` | `true` | Verify SSL peer certificate | +| `curl_ssl_verify_host` | `FLOW_PHPUNIT_OTEL_CURL_SSL_VERIFY_HOST` | `true` | Verify SSL host name | +| `curl_ssl_cert_path` | `FLOW_PHPUNIT_OTEL_CURL_SSL_CERT_PATH` | — | Path to client SSL certificate | +| `curl_ssl_key_path` | `FLOW_PHPUNIT_OTEL_CURL_SSL_KEY_PATH` | — | Path to client SSL private key | +| `curl_ca_info_path` | `FLOW_PHPUNIT_OTEL_CURL_CA_INFO_PATH` | — | Path to CA certificate bundle | +| `curl_serializer` | `FLOW_PHPUNIT_OTEL_CURL_SERIALIZER` | `json` | Payload serializer: `json` or `protobuf` | ### gRPC transport (`transport=grpc`) @@ -81,10 +83,10 @@ Requires the `grpc` PHP extension and the `google/protobuf` package. Payload is gRPC has no separate connect timeout — `grpc_timeout_ms` is the per-call deadline that bounds DNS, connect, send and receive together. -| Parameter | Environment variable | Default | Description | -|-------------------|-------------------------------------|---------|--------------------------------------------------------| -| `grpc_timeout_ms` | `FLOW_PHPUNIT_OTEL_GRPC_TIMEOUT_MS` | `250` | Per-call deadline in milliseconds | -| `grpc_insecure` | `FLOW_PHPUNIT_OTEL_GRPC_INSECURE` | `true` | Use insecure channel credentials | +| Parameter | Environment variable | Default | Description | +|-------------------|-------------------------------------|---------|-----------------------------------| +| `grpc_timeout_ms` | `FLOW_PHPUNIT_OTEL_GRPC_TIMEOUT_MS` | `250` | Per-call deadline in milliseconds | +| `grpc_insecure` | `FLOW_PHPUNIT_OTEL_GRPC_INSECURE` | `true` | Use insecure channel credentials | ### Stream transport (`transport=stream`) @@ -92,10 +94,10 @@ Writes JSONL telemetry to a file or `php://` stream wrapper instead of an OTLP c ([OTLP File Exporter spec](https://opentelemetry.io/docs/specs/otel/protocol/file-exporter/)). The `endpoint` parameter holds the destination path. Useful for local debugging or pipelines that scrape JSONL from disk / stdout. -| Parameter | Environment variable | Default | Description | -|----------------------------|----------------------------------------------|---------|--------------------------------------------------------------| -| `stream_file_permissions` | `FLOW_PHPUNIT_OTEL_STREAM_FILE_PERMISSIONS` | `0644` | Permissions for newly created files (ignored for `php://`) | -| `stream_create_directories`| `FLOW_PHPUNIT_OTEL_STREAM_CREATE_DIRECTORIES`| `true` | Create parent directories of the destination if missing | +| Parameter | Environment variable | Default | Description | +|-----------------------------|-----------------------------------------------|---------|------------------------------------------------------------| +| `stream_file_permissions` | `FLOW_PHPUNIT_OTEL_STREAM_FILE_PERMISSIONS` | `0644` | Permissions for newly created files (ignored for `php://`) | +| `stream_create_directories` | `FLOW_PHPUNIT_OTEL_STREAM_CREATE_DIRECTORIES` | `true` | Create parent directories of the destination if missing | ## Error Handlers @@ -103,23 +105,23 @@ Telemetry-internal errors (exporter failures, transport timeouts) are routed to being thrown into the test runner. Pick one with the `error_handler` parameter; all options follow the same `error_handler_*` parameter family. -| Type | Description | -|--------------|----------------------------------------------------------------| -| `error_log` | Default. Writes via PHP's `error_log()` (stderr in CLI). | -| `noop` | Silently discards every Throwable. | -| `stream` | Appends to a file path or `php://` stream wrapper. | -| `syslog` | Local syslog via `openlog` / `syslog` / `closelog`. | -| `udp_syslog` | RFC 5424 syslog frames over UDP to a remote collector. | +| Type | Description | +|--------------|----------------------------------------------------------| +| `error_log` | Default. Writes via PHP's `error_log()` (stderr in CLI). | +| `noop` | Silently discards every Throwable. | +| `stream` | Appends to a file path or `php://` stream wrapper. | +| `syslog` | Local syslog via `openlog` / `syslog` / `closelog`. | +| `udp_syslog` | RFC 5424 syslog frames over UDP to a remote collector. | Mixing parameters across handler types throws `InvalidArgumentException` at boot. ### `error_log` (default) -| Parameter | Environment variable | Default | Description | -|---------------------------------|---------------------------------------------------|--------------------|--------------------------------------------------------------| -| `error_handler_message_type` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_MESSAGE_TYPE` | `operating_system` | `operating_system` (0), `email` (1), `file` (3), `sapi` (4) | +| Parameter | Environment variable | Default | Description | +|---------------------------------|---------------------------------------------------|--------------------|---------------------------------------------------------------| +| `error_handler_message_type` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_MESSAGE_TYPE` | `operating_system` | `operating_system` (0), `email` (1), `file` (3), `sapi` (4) | | `error_handler_expand_newlines` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_EXPAND_NEWLINES` | `false` | Emit one `error_log()` call per line of the formatted message | -| `error_handler_message_prefix` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_MESSAGE_PREFIX` | `[flow-telemetry]` | Prefix prepended to every message | +| `error_handler_message_prefix` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_MESSAGE_PREFIX` | `[flow-telemetry]` | Prefix prepended to every message | ### `noop` @@ -130,33 +132,33 @@ intentionally absent). Appends formatted Throwables (one per line) to a destination. The handle is opened lazily on first error and reused. -| Parameter | Environment variable | Default | Description | -|------------------------------------|------------------------------------------------------|--------------------|--------------------------------------------------------------| -| `error_handler_destination` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_DESTINATION` | — | File path or `php://stdout`/`php://stderr`/etc. (required) | -| `error_handler_file_permissions` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_FILE_PERMISSIONS` | `0644` | Permissions for newly created files (ignored for `php://`) | -| `error_handler_create_directories` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_CREATE_DIRECTORIES` | `true` | Create parent directories of the destination if missing | -| `error_handler_message_prefix` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_MESSAGE_PREFIX` | `[flow-telemetry]` | Prefix prepended to every line | +| Parameter | Environment variable | Default | Description | +|------------------------------------|------------------------------------------------------|--------------------|------------------------------------------------------------| +| `error_handler_destination` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_DESTINATION` | — | File path or `php://stdout`/`php://stderr`/etc. (required) | +| `error_handler_file_permissions` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_FILE_PERMISSIONS` | `0644` | Permissions for newly created files (ignored for `php://`) | +| `error_handler_create_directories` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_CREATE_DIRECTORIES` | `true` | Create parent directories of the destination if missing | +| `error_handler_message_prefix` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_MESSAGE_PREFIX` | `[flow-telemetry]` | Prefix prepended to every line | ### `syslog` -| Parameter | Environment variable | Default | Description | -|----------------------------|----------------------------------------------|------------------|--------------------------------------------------------------| -| `error_handler_ident` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_IDENT` | `flow-telemetry` | Syslog identity tag | -| `error_handler_facility` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_FACILITY` | `user` | RFC 5424 facility (e.g. `user`, `local0`..`local7`, `mail`) | -| `error_handler_log_opts` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_LOG_OPTS` | `1` (`LOG_PID`) | Bitmask of `LOG_*` flags passed to `openlog()` | -| `error_handler_severity` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_SEVERITY` | `error` | RFC 5424 severity (e.g. `error`, `warning`, `notice`, `info`) | +| Parameter | Environment variable | Default | Description | +|--------------------------|--------------------------------------------|------------------|---------------------------------------------------------------| +| `error_handler_ident` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_IDENT` | `flow-telemetry` | Syslog identity tag | +| `error_handler_facility` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_FACILITY` | `user` | RFC 5424 facility (e.g. `user`, `local0`..`local7`, `mail`) | +| `error_handler_log_opts` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_LOG_OPTS` | `1` (`LOG_PID`) | Bitmask of `LOG_*` flags passed to `openlog()` | +| `error_handler_severity` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_SEVERITY` | `error` | RFC 5424 severity (e.g. `error`, `warning`, `notice`, `info`) | ### `udp_syslog` Sends RFC 5424 syslog frames over UDP. Useful when test runs ship to a centralized log aggregator. -| Parameter | Environment variable | Default | Description | -|--------------------------|--------------------------------------------|------------------|--------------------------------------------------------------| -| `error_handler_host` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_HOST` | — | Remote syslog host (required) | -| `error_handler_port` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_PORT` | `514` | Remote syslog port | -| `error_handler_ident` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_IDENT` | `flow-telemetry` | Syslog identity tag | -| `error_handler_facility` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_FACILITY` | `user` | RFC 5424 facility | -| `error_handler_severity` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_SEVERITY` | `error` | RFC 5424 severity | +| Parameter | Environment variable | Default | Description | +|--------------------------|--------------------------------------------|------------------|-------------------------------| +| `error_handler_host` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_HOST` | — | Remote syslog host (required) | +| `error_handler_port` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_PORT` | `514` | Remote syslog port | +| `error_handler_ident` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_IDENT` | `flow-telemetry` | Syslog identity tag | +| `error_handler_facility` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_FACILITY` | `user` | RFC 5424 facility | +| `error_handler_severity` | `FLOW_PHPUNIT_OTEL_ERROR_HANDLER_SEVERITY` | `error` | RFC 5424 severity | ## Authentication @@ -207,14 +209,17 @@ Invalid header format (missing `=`, empty name) will throw `InvalidArgumentExcep Every parameter has an env var counterpart (see the tables above). -**Precedence:** environment variable > `` in `phpunit.xml` > default. Empty-string env vars are treated as unset. +**Precedence:** environment variable > `` in `phpunit.xml` > default. Empty-string env vars are treated as +unset. **Lookup order:** `$_ENV` → `$_SERVER` → `getenv()`. Values loaded by Symfony DotEnv or `vlucas/phpdotenv` (which populate `$_ENV` / `$_SERVER` but don't always call `putenv()`) are picked up the same as shell env vars. > [!IMPORTANT] -> When setting boolean env vars through PHPUnit's `` directive in `phpunit.xml.dist`, add `verbatim="true"` — otherwise -> PHPUnit casts the bare strings `"true"` / `"false"` to PHP booleans, which `putenv()` then stringifies to `"1"` / `""`, and +> When setting boolean env vars through PHPUnit's `` directive in `phpunit.xml.dist`, add `verbatim="true"` — +> otherwise +> PHPUnit casts the bare strings `"true"` / `"false"` to PHP booleans, which `putenv()` then stringifies to `"1"` / +`""`, and > the empty string is treated as unset by the resolver (falling back to the default). > > ```xml diff --git a/documentation/components/libs/telemetry.md b/documentation/components/libs/telemetry.md index d18ff799af..7f0d00aaa5 100644 --- a/documentation/components/libs/telemetry.md +++ b/documentation/components/libs/telemetry.md @@ -923,6 +923,10 @@ The `EnvironmentDetector` reads standard OpenTelemetry environment variables: - `OTEL_SERVICE_NAME` - Sets the `service.name` attribute - `OTEL_RESOURCE_ATTRIBUTES` - Sets additional attributes in `key=value,key2=value2` format +Per the OpenTelemetry Resource SDK specification, `,` and `=` inside keys and values MUST be +percent-encoded (other characters MAY be); both keys and values are percent-decoded. For example, +`note=a%2Cb` yields the attribute `note` with value `a,b`. + ```bash export OTEL_SERVICE_NAME=my-service export OTEL_RESOURCE_ATTRIBUTES=service.version=1.0.0,deployment.environment.name=production diff --git a/documentation/upgrading.md b/documentation/upgrading.md index bba012bb0d..52abe38d84 100644 --- a/documentation/upgrading.md +++ b/documentation/upgrading.md @@ -121,7 +121,8 @@ to_pgsql_table($client, 'users')->withTypesMap(new EntryTypesMap( )); ``` -### 6) `flow-php/symfony-postgresql-messenger` - `messenger_messages` time columns use `timestamp` instead of `timestamptz` +### 6) `flow-php/symfony-postgresql-messenger` - `messenger_messages` time columns use `timestamp` instead of +`timestamptz` | Column type for `created_at`, `available_at`, `delivered_at` | Before | After | |--------------------------------------------------------------|---------------|-------------| @@ -132,11 +133,21 @@ Existing tables, realign the column type (UTC instants preserved): ```sql ALTER TABLE messenger_messages - ALTER COLUMN created_at TYPE timestamp USING created_at AT TIME ZONE 'UTC', - ALTER COLUMN available_at TYPE timestamp USING available_at AT TIME ZONE 'UTC', - ALTER COLUMN delivered_at TYPE timestamp USING delivered_at AT TIME ZONE 'UTC'; + ALTER COLUMN created_at TYPE TIMESTAMP USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN available_at TYPE TIMESTAMP USING available_at AT TIME ZONE 'UTC', + ALTER COLUMN delivered_at TYPE TIMESTAMP USING delivered_at AT TIME ZONE 'UTC'; ``` +### 7) `flow-php/telemetry` - `OTEL_RESOURCE_ATTRIBUTES` keys and values are percent-decoded, not backslash-escaped + +| Escaping a `,` or `=` in `OTEL_RESOURCE_ATTRIBUTES` | Before | After | +|-----------------------------------------------------|---------------------------|-----------------------------| +| literal comma in a value | `key=value\,with\,commas` | `key=value%2Cwith%2Ccommas` | +| literal `=` in a value | not supported | `key=a%3Db` | + +Re-encode any `OTEL_RESOURCE_ATTRIBUTES` that relied on backslash escaping; both keys and values are now +percent-decoded. + --- ## Upgrading from 0.37.x to 0.38.x diff --git a/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/Configuration.php b/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/Configuration.php index 569073fdbf..5e347bcba6 100644 --- a/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/Configuration.php +++ b/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/Configuration.php @@ -128,6 +128,9 @@ 'error_handler_severity', ]; + /** + * @param array $resourceAttributes + */ public function __construct( public string $serviceName, public CurlTransportConfig|GrpcTransportConfig|StreamTransportConfig $transport, @@ -138,6 +141,7 @@ public function __construct( public int $batchSize, public ErrorLogHandlerConfig|NullErrorHandlerConfig|StreamErrorHandlerConfig|SyslogErrorHandlerConfig|UdpSyslogErrorHandlerConfig $errorHandler, public bool $memoryRealUsage = false, + public array $resourceAttributes = [], ) {} public static function fromParameters(ParameterCollection $parameters): self @@ -168,6 +172,7 @@ public static function fromParameters(ParameterCollection $parameters): self batchSize: self::resolveInt($parameters, 'batch_size', self::DEFAULT_BATCH_SIZE), errorHandler: self::resolveErrorHandler($parameters), memoryRealUsage: self::resolveBool($parameters, 'memory_real_usage', false), + resourceAttributes: self::parseResourceAttributes(self::resolve($parameters, 'resource_attributes') ?? ''), ); } @@ -280,6 +285,44 @@ private static function parseHeaders(string $raw): array return $headers; } + /** + * @return array + */ + private static function parseResourceAttributes(string $raw): array + { + if ($raw === '') { + return []; + } + + $attributes = []; + + foreach (explode(',', $raw) as $entry) { + $entry = trim($entry); + + if ($entry === '') { + continue; + } + + if (!str_contains($entry, '=')) { + throw new InvalidArgumentException(sprintf( + 'Invalid resource attribute entry "%s", expected format "name=value".', + $entry, + )); + } + + [$name, $value] = explode('=', $entry, 2); + $name = urldecode(trim($name)); + + if ($name === '') { + throw new InvalidArgumentException('Resource attribute name cannot be empty.'); + } + + $attributes[$name] = urldecode($value); + } + + return $attributes; + } + private static function readEnv(string $fullName): ?string { $value = $_ENV[$fullName] ?? $_SERVER[$fullName] ?? getenv($fullName); diff --git a/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/TelemetryFactory.php b/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/TelemetryFactory.php index d75ede6b4b..f6fa9e8947 100644 --- a/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/TelemetryFactory.php +++ b/src/bridge/phpunit/telemetry/src/Flow/Bridge/PHPUnit/Telemetry/TelemetryFactory.php @@ -44,7 +44,8 @@ public static function create(Configuration $config): Telemetry 'service.name' => $config->serviceName, 'telemetry.sdk.name' => 'flow-php-phpunit-telemetry', 'telemetry.sdk.language' => 'php', - ])); + ])) + ->merge(resource($config->resourceAttributes)); $clock = new SystemClock(); $contextStorage = memory_context_storage(); diff --git a/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Mother/ConfigurationMother.php b/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Mother/ConfigurationMother.php index 523227bd4f..20adad1c11 100644 --- a/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Mother/ConfigurationMother.php +++ b/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Mother/ConfigurationMother.php @@ -154,4 +154,22 @@ public static function withRealMemoryUsage(): Configuration memoryRealUsage: true, ); } + + /** + * @param array $resourceAttributes + */ + public static function withResourceAttributes(array $resourceAttributes): Configuration + { + return new Configuration( + serviceName: 'phpunit', + transport: self::defaultTransport(), + emitTraces: true, + emitMetrics: true, + emitTestSpans: true, + emitTestCaseSpans: true, + batchSize: Configuration::DEFAULT_BATCH_SIZE, + errorHandler: self::defaultErrorHandler(), + resourceAttributes: $resourceAttributes, + ); + } } diff --git a/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Unit/ConfigurationTest.php b/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Unit/ConfigurationTest.php index ff6652e9b1..a0012275e4 100644 --- a/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Unit/ConfigurationTest.php +++ b/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Unit/ConfigurationTest.php @@ -253,6 +253,7 @@ public function test_default_configuration_uses_curl_transport_with_localhost_en static::assertTrue($config->emitTestSpans); static::assertTrue($config->emitTestCaseSpans); static::assertFalse($config->memoryRealUsage); + static::assertSame([], $config->resourceAttributes); static::assertInstanceOf(CurlTransportConfig::class, $config->transport); static::assertSame('http://localhost:4318', $config->transport->endpoint); @@ -710,6 +711,89 @@ public function test_noop_error_handler_rejects_specific_params(): void ])); } + public function test_resource_attributes_duplicate_name_last_wins(): void + { + $config = Configuration::fromParameters(ParameterCollection::fromArray([ + 'resource_attributes' => 'team=first,team=second', + ])); + + static::assertSame(['team' => 'second'], $config->resourceAttributes); + } + + public function test_resource_attributes_empty_name_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Resource attribute name cannot be empty'); + + Configuration::fromParameters(ParameterCollection::fromArray([ + 'resource_attributes' => '=value', + ])); + } + + public function test_resource_attributes_empty_string_is_empty_array(): void + { + $config = Configuration::fromParameters(ParameterCollection::fromArray([ + 'resource_attributes' => '', + ])); + + static::assertSame([], $config->resourceAttributes); + } + + public function test_resource_attributes_env_var_wins_over_parameter(): void + { + putenv('FLOW_PHPUNIT_OTEL_RESOURCE_ATTRIBUTES=service.version=2.0.0'); + + try { + $config = Configuration::fromParameters(ParameterCollection::fromArray([ + 'resource_attributes' => 'service.version=1.0.0', + ])); + + static::assertSame(['service.version' => '2.0.0'], $config->resourceAttributes); + } finally { + putenv('FLOW_PHPUNIT_OTEL_RESOURCE_ATTRIBUTES'); + } + } + + public function test_resource_attributes_missing_equals_throws(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid resource attribute entry "team", expected format "name=value"'); + + Configuration::fromParameters(ParameterCollection::fromArray([ + 'resource_attributes' => 'team', + ])); + } + + public function test_resource_attributes_multiple_pairs_parsed(): void + { + $config = Configuration::fromParameters(ParameterCollection::fromArray([ + 'resource_attributes' => 'service.version=1.0.0,deployment.environment.name=ci', + ])); + + static::assertSame( + ['service.version' => '1.0.0', 'deployment.environment.name' => 'ci'], + $config->resourceAttributes, + ); + } + + public function test_resource_attributes_single_pair_parsed(): void + { + $config = Configuration::fromParameters(ParameterCollection::fromArray([ + 'resource_attributes' => 'service.version=1.0.0', + ])); + + static::assertSame(['service.version' => '1.0.0'], $config->resourceAttributes); + } + + public function test_resource_attributes_url_encoded_value_decoded(): void + { + $config = Configuration::fromParameters(ParameterCollection::fromArray([ + 'resource_attributes' => 'note=a%2Cb%20c', + ])); + + static::assertSame(['note' => 'a,b c'], $config->resourceAttributes); + } + public function test_server_superglobal_wins_over_getenv(): void { $_SERVER['FLOW_PHPUNIT_OTEL_ENDPOINT'] = 'https://from-server:4318'; diff --git a/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Unit/TelemetryFactoryTest.php b/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Unit/TelemetryFactoryTest.php index d2885a6812..da54d5c983 100644 --- a/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Unit/TelemetryFactoryTest.php +++ b/src/bridge/phpunit/telemetry/tests/Flow/Bridge/PHPUnit/Telemetry/Tests/Unit/TelemetryFactoryTest.php @@ -140,6 +140,17 @@ public function test_create_with_null_error_handler(): void $telemetry->shutdown(); } + public function test_create_with_resource_attributes(): void + { + $telemetry = TelemetryFactory::create(ConfigurationMother::withResourceAttributes([ + 'service.version' => '1.0.0', + 'deployment.environment.name' => 'ci', + ])); + + static::assertInstanceOf(Telemetry::class, $telemetry); + $telemetry->shutdown(); + } + public function test_create_with_stream_error_handler(): void { $config = $this->configWithErrorHandler(new StreamErrorHandlerConfig( diff --git a/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/EnvironmentDetector.php b/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/EnvironmentDetector.php index 68e5acae7e..95e9e3e88e 100644 --- a/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/EnvironmentDetector.php +++ b/src/lib/telemetry/src/Flow/Telemetry/Resource/Detector/EnvironmentDetector.php @@ -8,13 +8,12 @@ use Flow\Telemetry\Resource\Attribute\ServiceAttribute; use Flow\Telemetry\Resource\ResourceDetector; -use function count; +use function explode; use function getenv; -use function stripslashes; -use function strlen; use function strpos; use function substr; use function trim; +use function urldecode; /** * Detects resource attributes from environment variables. @@ -28,9 +27,11 @@ * OTEL_RESOURCE_ATTRIBUTES=key1=value1,key2=value2 * ``` * - * Special characters in values can be escaped with backslash: + * Per the OpenTelemetry Resource SDK specification, the `,` and `=` characters in keys and + * values MUST be percent-encoded (other characters MAY be percent-encoded); both keys and + * values are percent-decoded: * ``` - * OTEL_RESOURCE_ATTRIBUTES=key=value\,with\,commas + * OTEL_RESOURCE_ATTRIBUTES=key=value%2Cwith%2Ccommas * ``` * * OTEL_SERVICE_NAME takes precedence over service.name in OTEL_RESOURCE_ATTRIBUTES. @@ -83,9 +84,8 @@ private function parseResourceAttributes(): array } $attributes = []; - $pairs = $this->splitByComma($rawAttributes); - foreach ($pairs as $pair) { + foreach (explode(',', $rawAttributes) as $pair) { $pair = trim($pair); if ($pair === '') { @@ -98,58 +98,15 @@ private function parseResourceAttributes(): array continue; } - $key = trim(substr($pair, 0, $equalsPos)); - $value = trim(substr($pair, $equalsPos + 1)); + $key = urldecode(trim(substr($pair, 0, $equalsPos))); if ($key === '') { continue; } - $value = stripslashes($value); - $attributes[$key] = $value; + $attributes[$key] = urldecode(trim(substr($pair, $equalsPos + 1))); } return $attributes; } - - /** - * Split a string by commas, respecting escaped commas. - * - * @return array - */ - private function splitByComma(string $input): array - { - $result = []; - $current = ''; - $length = strlen($input); - $i = 0; - - while ($i < $length) { - $char = $input[$i]; - - if ($char === '\\' && ($i + 1) < $length) { - $current .= $char . $input[$i + 1]; - $i += 2; - - continue; - } - - if ($char === ',') { - $result[] = $current; - $current = ''; - $i++; - - continue; - } - - $current .= $char; - $i++; - } - - if ($current !== '' || count($result) > 0) { - $result[] = $current; - } - - return $result; - } } diff --git a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Resource/Detector/EnvironmentDetectorTest.php b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Resource/Detector/EnvironmentDetectorTest.php index ff3e48b455..f1e84b8a83 100644 --- a/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Resource/Detector/EnvironmentDetectorTest.php +++ b/src/lib/telemetry/tests/Flow/Telemetry/Tests/Unit/Resource/Detector/EnvironmentDetectorTest.php @@ -29,19 +29,42 @@ public function test_detect_allows_empty_values(): void static::assertSame('', $resource->get('key')); } - public function test_detect_handles_escaped_commas_in_values(): void + public function test_detect_percent_decodes_commas_and_equals_in_values(): void { - putenv('OTEL_RESOURCE_ATTRIBUTES=key=value\,with\,commas,other=normal'); + putenv('OTEL_RESOURCE_ATTRIBUTES=key=value%2Cwith%2Ccommas%3Dand%3Dequals,other=normal'); $detector = new EnvironmentDetector(); $resource = $detector->detect(); static::assertTrue($resource->has('key')); static::assertTrue($resource->has('other')); - static::assertSame('value,with,commas', $resource->get('key')); + static::assertSame('value,with,commas=and=equals', $resource->get('key')); static::assertSame('normal', $resource->get('other')); } + public function test_detect_percent_decodes_keys(): void + { + putenv('OTEL_RESOURCE_ATTRIBUTES=weird%2Ckey=value,space%20key=data'); + + $detector = new EnvironmentDetector(); + $resource = $detector->detect(); + + static::assertTrue($resource->has('weird,key')); + static::assertTrue($resource->has('space key')); + static::assertSame('value', $resource->get('weird,key')); + static::assertSame('data', $resource->get('space key')); + } + + public function test_detect_percent_decodes_whitespace_in_values(): void + { + putenv('OTEL_RESOURCE_ATTRIBUTES=key=hello%20world'); + + $detector = new EnvironmentDetector(); + $resource = $detector->detect(); + + static::assertSame('hello world', $resource->get('key')); + } + public function test_detect_ignores_empty_keys(): void { putenv('OTEL_RESOURCE_ATTRIBUTES==value,key=data');