Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions benchmarks/src/Service/Seal/SealEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,16 @@ public static function open(SealSchema $schema): EngineInterface
public static function recreateIndex(EngineInterface $engine, string $index): void
{
if ($engine->existIndex($index)) {
$engine->dropIndex($index, ['return_slow_promise_result' => true])?->wait();
$engine->dropIndex($index, ['return_slow_promise_result' => true])->wait();
}

$engine->createIndex($index, ['return_slow_promise_result' => true])?->wait();
$engine->createIndex($index, ['return_slow_promise_result' => true])->wait();
}

public static function dropIndex(EngineInterface $engine, string $index): void
{
if ($engine->existIndex($index)) {
$engine->dropIndex($index, ['return_slow_promise_result' => true])?->wait();
$engine->dropIndex($index, ['return_slow_promise_result' => true])->wait();
}
}
}
38 changes: 28 additions & 10 deletions documentation/components/bridges/psr18-telemetry-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,23 +57,41 @@ $request = new Request('GET', 'https://api.example.com/users');
$response = $traceableClient->sendRequest($request);

// A span is automatically created with:
// - Name: "GET api.example.com"
// - Name: "GET"
// - Kind: CLIENT
// - Attributes: http.method, http.url, http.scheme, http.host, http.status_code
// - Status: OK for 2xx/3xx, ERROR for 4xx/5xx
// - Attributes: http.request.method, url.full, url.scheme, server.address, server.port, http.response.status_code
// - Status: unset for 1xx-3xx, ERROR for 4xx/5xx
```

### Span Attributes

The traceable client automatically records the following span attributes:

| Attribute | Description | Example |
|-----------|-------------|---------|
| `http.method` | HTTP method | `GET`, `POST` |
| `http.url` | Full request URL | `https://api.example.com/users` |
| `http.scheme` | URL scheme | `https` |
| `http.host` | Target host | `api.example.com` |
| `http.status_code` | Response status code | `200`, `404`, `500` |
| Attribute | Description | Example |
|-----------------------------|-----------------------------------------------------------|----------------------------------------------|
| `http.request.method` | HTTP method | `GET`, `POST` |
| `url.full` | Full request URL, redacted | `https://api.example.com/users?sig=REDACTED` |
| `url.scheme` | URL scheme | `https` |
| `server.address` | Target host | `api.example.com` |
| `server.port` | Target port, the scheme default when the URL has none | `443` |
| `http.response.status_code` | Response status code | `200`, `404`, `500` |
| `error.type` | Status code of a 4xx/5xx response, or the exception class | `404`, `RuntimeException` |

`url.full` and `exception.message` carry the URL with userinfo and the values of sensitive query parameters replaced by
`REDACTED` (see [URL sanitization](/documentation/components/libs/telemetry.md#url-sanitization)):

```php
use Flow\Telemetry\Sanitizer\UrlSanitizer;

use function Flow\Bridge\Psr18\Telemetry\DSL\psr18_traceable_client;
use function Flow\Telemetry\DSL\url_sanitizer;

$traceableClient = psr18_traceable_client(
$httpClient,
$telemetry,
url_sanitizer([...UrlSanitizer::DEFAULT_SENSITIVE_QUERY_PARAMETERS, 'fb_exchange_token']),
);
```

### Error Handling

Expand Down
31 changes: 31 additions & 0 deletions documentation/components/bridges/symfony-telemetry-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -1435,6 +1435,9 @@ flow_telemetry:
- '/^debug\..*/'
```

`url.full`, `url.scheme`, `server.address` and `server.port` describe the effective URL the client sends
(`$response->getInfo('url')`), so `base_uri` and the `query` option are included.

#### PSR-18 Client

Traces PSR-18 HTTP client requests.
Expand All @@ -1448,6 +1451,34 @@ flow_telemetry:
- 'app.internal_client'
```

#### Sensitive query parameters

`http_kernel` (`url.query`), `http_client` and `psr18_client` (`url.full`, `exception.message`) replace userinfo and
the values of listed query parameters with `REDACTED`. The default is `UrlSanitizer::DEFAULT_SENSITIVE_QUERY_PARAMETERS`
(OpenTelemetry, OAuth 2.0 / OpenID Connect, API key and credential keys, see
[URL sanitization](/documentation/components/libs/telemetry.md#url-sanitization)).

A configured list **replaces** the default. Keys are case-sensitive. To add keys, extend the constant in a PHP config
file:

```php
// config/packages/flow_telemetry.php
use Flow\Telemetry\Sanitizer\UrlSanitizer;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;

return static function (ContainerConfigurator $container): void {
$container->extension('flow_telemetry', [
'instrumentation' => [
'sanitization' => [
'url' => [
'sensitive_query_parameters' => [...UrlSanitizer::DEFAULT_SENSITIVE_QUERY_PARAMETERS, 'fb_exchange_token'],
],
],
],
]);
};
```

#### Doctrine DBAL

Traces database queries.
Expand Down
34 changes: 34 additions & 0 deletions documentation/components/libs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,40 @@ try {
Use `withoutSuppressedTracing()` to lift suppression for a nested region (e.g. to trace real work inside an otherwise
suppressed loop).

### URL sanitization

HTTP instrumentations (PSR-18 client, Symfony HttpClient, Symfony HttpKernel) write `url.full`, `url.query` and
exception messages through a `UrlSanitizer`: userinfo and the values of sensitive query parameters become `REDACTED`,
keys stay.

```php
<?php

use Flow\Telemetry\Sanitizer\UrlSanitizer;

use function Flow\Telemetry\DSL\url_sanitizer;

url_sanitizer()->url('https://u:p@graph.facebook.com/me?access_token=T&fields=id');
// https://REDACTED:REDACTED@graph.facebook.com/me?access_token=REDACTED&fields=id

url_sanitizer([...UrlSanitizer::DEFAULT_SENSITIVE_QUERY_PARAMETERS, 'fb_exchange_token'])->url('https://h/me?fb_exchange_token=T');
// https://h/me?fb_exchange_token=REDACTED
```

`UrlSanitizer::DEFAULT_SENSITIVE_QUERY_PARAMETERS` holds:

| Source | Keys |
|--------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| OpenTelemetry semantic conventions | `AWSAccessKeyId`, `Signature`, `sig`, `X-Amz-Signature`, `X-Amz-Credential`, `X-Amz-Security-Token`, `X-Goog-Signature` |
| OAuth 2.0 (RFC 6749, 6750, 7009, 7521, 7636, 8628, 8693), OpenID Connect | `access_token`, `refresh_token`, `id_token`, `id_token_hint`, `logout_token`, `client_secret`, `client_assertion`, `assertion`, `code`, `code_verifier`, `device_code`, `subject_token`, `actor_token`, `token` |
| API keys, credentials, sessions (Sentry and Datadog scrubbing defaults) | `api_key`, `apikey`, `api-key`, `key`, `access_key`, `secret_key`, `private_key`, `secret`, `signature`, `password`, `passwd`, `pwd`, `auth`, `jwt`, `session`, `sessionid`, `PHPSESSID` |

The list goes beyond the OpenTelemetry default, which covers only signed cloud-storage URLs. Keys are case-sensitive.
A given list replaces the default, it does not extend it; `[]` keeps only the userinfo redaction.

`exception.stacktrace` (spans, log records, `Throwable` attribute values) never carries function argument values,
whatever `zend.exception_ignore_args` is set to.

### Metrics

Metrics are numerical measurements that track the state and performance of your application over time. Flow Telemetry
Expand Down
8 changes: 8 additions & 0 deletions documentation/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ public function scan(FlowContext $context, FileScan $scan): Generator
}
```

### 8) `flow-php/telemetry`, `flow-php/psr18-telemetry-bridge`, `flow-php/symfony-telemetry-bundle` - HTTP URLs are redacted, stack traces carry no argument values

| Before | After |
|---------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `url.full` (PSR-18 and Symfony HttpClient), `url.query` (HttpKernel), `exception.message` and the client span status description held the raw URL | userinfo and the values of `UrlSanitizer::DEFAULT_SENSITIVE_QUERY_PARAMETERS` (OpenTelemetry, OAuth 2.0 / OpenID Connect, API key and credential keys) are `REDACTED`; configure the list with `url_sanitizer([...])` or `flow_telemetry.instrumentation.sanitization.url.sensitive_query_parameters` (replaces the default) |
| Symfony HttpClient `url.full`, `url.scheme`, `server.address`, `server.port` came from the `$url` argument (`/me`, `unknown` with `base_uri`) | they come from the effective URL, `base_uri` and the `query` option included |
| `exception.stacktrace` was `getTraceAsString()`, with argument values when `zend.exception_ignore_args` is off | `StackTrace::format()`: every frame renders `()`, whatever `zend.exception_ignore_args` is |

---

## Upgrading from 0.45.x to 0.46.x
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function dropIndexes(): void
{
foreach ($this->indexes as [$engine, $index]) {
if ($engine->existIndex($index)) {
$engine->dropIndex($index, ['return_slow_promise_result' => true])?->wait();
$engine->dropIndex($index, ['return_slow_promise_result' => true])->wait();
}
}

Expand All @@ -42,10 +42,10 @@ public function engine(Schema $schema): EngineInterface

foreach ($schema->indexes as $index => $_value) {
if ($engine->existIndex($index)) {
$engine->dropIndex($index, ['return_slow_promise_result' => true])?->wait();
$engine->dropIndex($index, ['return_slow_promise_result' => true])->wait();
}

$engine->createIndex($index, ['return_slow_promise_result' => true])?->wait();
$engine->createIndex($index, ['return_slow_promise_result' => true])->wait();

$this->indexes[] = [$engine, $index];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@
use Flow\Documentation\Attribute\DocumentationDSL;
use Flow\Documentation\Attribute\Module;
use Flow\Documentation\Attribute\Type as DSLType;
use Flow\Telemetry\Sanitizer\UrlSanitizer;
use Flow\Telemetry\Telemetry;
use Psr\Http\Client\ClientInterface;

#[DocumentationDSL(module: Module::PSR18_TELEMETRY_BRIDGE, type: DSLType::HELPER)]
function psr18_traceable_client(ClientInterface $client, Telemetry $telemetry): PSR18TraceableClient
{
return new PSR18TraceableClient($client, $telemetry);
function psr18_traceable_client(
ClientInterface $client,
Telemetry $telemetry,
UrlSanitizer $urlSanitizer = new UrlSanitizer(),
): PSR18TraceableClient {
return new PSR18TraceableClient($client, $telemetry, $urlSanitizer);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
namespace Flow\Bridge\Psr18\Telemetry;

use DateTimeImmutable;
use Flow\Telemetry\ClientUrlAttributes;
use Flow\Telemetry\PackageVersion;
use Flow\Telemetry\Sanitizer\UrlSanitizer;
use Flow\Telemetry\SemConvAttributes;
use Flow\Telemetry\Telemetry;
use Flow\Telemetry\Tracer\SpanKind;
Expand All @@ -18,12 +20,16 @@

final readonly class PSR18TraceableClient implements ClientInterface
{
private ClientUrlAttributes $clientUrlAttributes;

private Tracer $tracer;

public function __construct(
private ClientInterface $client,
Telemetry $telemetry,
private UrlSanitizer $urlSanitizer = new UrlSanitizer(),
) {
$this->clientUrlAttributes = new ClientUrlAttributes($urlSanitizer);
$this->tracer = $telemetry->tracer(
'flow.psr18.http_client',
PackageVersion::get('flow-php/psr18-telemetry-bridge'),
Expand All @@ -32,22 +38,9 @@ public function __construct(

public function sendRequest(RequestInterface $request): ResponseInterface
{
$uri = $request->getUri();
$method = $request->getMethod();
$scheme = $uri->getScheme() ?: 'http';
$host = $uri->getHost() ?: 'unknown';
$port = $uri->getPort();
$url = (string) $uri;

$attributes = [
SemConvAttributes::HTTP_REQUEST_METHOD => $method,
SemConvAttributes::URL_FULL => $url,
SemConvAttributes::URL_SCHEME => $scheme,
SemConvAttributes::SERVER_ADDRESS => $host,
// OTEL HTTP semconv: server.port is Required on client spans; fall back to the
// scheme default when the URI carries no explicit port.
SemConvAttributes::SERVER_PORT => $port ?? ($scheme === 'https' ? 443 : 80),
];
$url = (string) $request->getUri();
$attributes = [SemConvAttributes::HTTP_REQUEST_METHOD => $method] + $this->clientUrlAttributes->of($url);

// OTEL HTTP semconv: client span name is "{method}" - host would be per-host cardinality.
$span = $this->tracer->span($method, SpanKind::CLIENT, $attributes);
Expand All @@ -67,9 +60,11 @@ public function sendRequest(RequestInterface $request): ResponseInterface

return $response;
} catch (Throwable $exception) {
$span->recordException($exception, new DateTimeImmutable());
$message = $this->urlSanitizer->message($exception->getMessage(), $url);

$span->recordException($exception, new DateTimeImmutable(), ['exception.message' => $message]);
$span->setAttribute(SemConvAttributes::ERROR_TYPE, $exception::class);
$span->setStatus(SpanStatus::error($exception->getMessage()));
$span->setStatus(SpanStatus::error($message));

throw $exception;
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,47 @@
use RuntimeException;

use function Flow\Bridge\Psr18\Telemetry\DSL\psr18_traceable_client;
use function Flow\Telemetry\DSL\url_sanitizer;

final class PSR18TraceableClientTest extends TestCase
{
public function test_custom_sanitizer_redacts_configured_query_parameters_and_userinfo(): void
{
$spanProcessor = new MemorySpanProcessor(new VoidExporter());

$client = $this->createStub(ClientInterface::class);
$client->method('sendRequest')->willReturn(new Response(200));

psr18_traceable_client(
$client,
TelemetryMother::withSpanProcessor($spanProcessor),
url_sanitizer(['access_token']),
)->sendRequest(new Request('GET', 'https://u:p@graph.facebook.com/me?access_token=SECRET&fields=id'));

static::assertSame(
'https://REDACTED:REDACTED@graph.facebook.com/me?access_token=REDACTED&fields=id',
$spanProcessor->endedSpans()[0]->attributes()['url.full'],
);
}

public function test_default_sanitizer_redacts_sensitive_query_parameters(): void
{
$spanProcessor = new MemorySpanProcessor(new VoidExporter());

$client = $this->createStub(ClientInterface::class);
$client->method('sendRequest')->willReturn(new Response(200));

psr18_traceable_client($client, TelemetryMother::withSpanProcessor($spanProcessor))->sendRequest(new Request(
'GET',
'https://bucket.s3.amazonaws.com/file?X-Amz-Signature=SECRET&access_token=SECRET&fields=id',
));

static::assertSame(
'https://bucket.s3.amazonaws.com/file?X-Amz-Signature=REDACTED&access_token=REDACTED&fields=id',
$spanProcessor->endedSpans()[0]->attributes()['url.full'],
);
}

public function test_emits_no_spans_when_tracing_is_suppressed(): void
{
$spanProcessor = new MemorySpanProcessor(new VoidExporter());
Expand All @@ -35,6 +73,38 @@ public function test_emits_no_spans_when_tracing_is_suppressed(): void
);
}

public function test_exception_message_and_status_description_hold_redacted_url(): void
{
$spanProcessor = new MemorySpanProcessor(new VoidExporter());

$client = $this->createStub(ClientInterface::class);
$client
->method('sendRequest')
->willThrowException(
new RuntimeException('Could not reach "https://graph.facebook.com/me?access_token=SECRET".'),
);

$this->expectException(RuntimeException::class);

try {
psr18_traceable_client(
$client,
TelemetryMother::withSpanProcessor($spanProcessor),
url_sanitizer(['access_token']),
)->sendRequest(new Request('GET', 'https://graph.facebook.com/me?access_token=SECRET'));
} finally {
$span = $spanProcessor->endedSpans()[0];
static::assertSame(
'Could not reach "https://graph.facebook.com/me?access_token=REDACTED".',
$span->events()[0]->attributes()['exception.message'],
);
static::assertSame(
'Could not reach "https://graph.facebook.com/me?access_token=REDACTED".',
$span->status()?->description,
);
}
}

public function test_exception_is_recorded_and_rethrown(): void
{
$spanProcessor = new MemorySpanProcessor(new VoidExporter());
Expand Down
1 change: 1 addition & 0 deletions src/bridge/symfony/telemetry-bundle/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"flow-php/telemetry-otlp-bridge": "self.version",
"nyholm/psr7": "^1.8",
"symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0",
"symfony/http-client": "^6.4 || ^7.4 || ^8.0",
"symfony/messenger": "^6.4 || ^7.4 || ^8.0",
"symfony/routing": "^6.4 || ^7.4 || ^8.0",
"symfony/security-bundle": "^6.4 || ^7.4 || ^8.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public function process(ContainerBuilder $container): void
$definition->setArgument(0, new Reference($decoratedId));
$definition->setArgument(1, new Reference(Telemetry::class));
$definition->setArgument(2, $serviceId);
$definition->setArgument(3, new Reference('flow.telemetry.url_sanitizer'));

$container->setDefinition($decoratorId, $definition);
}
Expand Down
Loading
Loading