vigil/otel-alert-bundle

Symfony bundle enriching OpenTelemetry with exception capture, filtering, and notifications (Jira, Webhook)

Maintainers

Package info

gitlab.com/devapmailer/otel-alert-bundle

Issues

Type:symfony-bundle

pkg:composer/vigil/otel-alert-bundle

Statistics

Installs: 19

Dependents: 0

Suggesters: 0

Stars: 0

v1.6.0 2026-04-29 10:42 UTC

This package is auto-updated.

Last update: 2026-04-29 08:43:51 UTC


README

🇬🇧 English | 🇫🇷 Français

English

Symfony bundle that enriches OpenTelemetry instrumentation with automatic exception capture, configurable filtering rules, and multi-channel notifications (Jira, Webhook, Email).

Table of contents

Compatibility

PHPSymfony 6.1Symfony 7.xSymfony 8.xAMQP instrumentation
8.1✅✅✅❌ requires PHP 8.2+
8.2âś…âś…âś…âś…
8.3âś…âś…âś…âś…

Installation

1. Require the bundle

composer require vigil/otel-alert-bundle

Symfony Flex automatically registers the bundle in config/bundles.php.

2. Run the install wizard

Required — do not skip. The bundle needs a configuration file and OTel packages that are not installed automatically. The wizard handles everything in one step.

php bin/console otel:install

The wizard:

  • installs the required OTel packages via Composer
  • detects Messenger, AMQP, and your PHP version
  • asks which notifiers to enable (Jira, Webhook, Email) and collects their settings
  • generates config/packages/otel_alert.yaml and appends credentials to .env.local
  • outputs ready-to-copy Kubernetes/Docker deployment instructions

3. Minimal manual configuration

# config/packages/otel_alert.yaml
otel_alert:
    enabled: true

    jira:
        enabled: true
        app_name: my-api
        host: '%env(JIRA_HOST)%'
        username: '%env(JIRA_USERNAME)%'
        api_token: '%env(JIRA_API_TOKEN)%'
        project_key: MYPROJECT

    webhook:
        enabled: true
        url: '%env(WEBHOOK_URL)%'

    email:
        enabled: true
        from: noreply@myapp.com
        contacts:
            - dev@myapp.com
# .env.local
JIRA_HOST=https://mycompany.atlassian.net
JIRA_USERNAME=myemail@company.com
JIRA_API_TOKEN=          # Jira Cloud API token
WEBHOOK_URL=https://hooks.slack.com/services/...

Important — Kubernetes deployments: OTEL_* environment variables must be defined in your deployment.yaml, not in .env. The OTel PHP SDK initialises at PHP-FPM startup, before Symfony loads .env. See the Kubernetes section below.

How it works

HTTP request → exception thrown
        │
        â–Ľ
ExceptionListener (kernel.exception, priority -100)
        │
        ├── AlertContext::fromRequest()      builds immutable context (route, status, traceId…)
        ├── AlertContextHolder::all()        injects business attributes set before the exception
        ├── AlertEnrichableInterface         reads business attributes from the exception (if implemented)
        ├── AlertRulesEngine::evaluate()     decides CAPTURE or IGNORE
        ├── enrichCurrentSpan()              marks OTel span as error in Grafana Tempo
        └── AlertDispatcher::dispatch()      sends without blocking the HTTP response
                │
                └── NotifierChain
                        ├── JiraNotifier    creates ticket via REST API v3
                        ├── WebhookNotifier POSTs to any endpoint
                        └── EmailNotifier   sends HTML email to configured contacts

Business context in alerts

By default, when an exception occurs, the bundle knows the route, HTTP status, and trace ID — but not the business object being processed (the Book, Order, or User). Two mechanisms let you add that context.

Option 1 — AlertTrackableInterface (recommended)

Implement the interface on your entity. The bundle automatically tracks it whenever it appears as a controller argument — no manual code required in your controllers or services.

// src/Entity/Book.php
use Vigil\OtelAlertBundle\AlertTrackableInterface;

class Book implements AlertTrackableInterface
{
    public function toAlertContext(): array
    {
        return [
            'book.id'     => $this->id,
            'book.title'  => $this->title,
            'book.status' => $this->status,
        ];
    }
}
// Controller — no mention of the bundle
#[Route('/books/{id}/publish', methods: ['POST'])]
public function publish(Book $book): JsonResponse
{
    $this->publisher->publish($book); // tracked automatically
    return $this->json(['ok']);
}

toAlertContext() is called at exception time, not when the entity is loaded. If your service modifies $book->status before the exception fires, the alert sees the modified value.

This works with standard Symfony (#[MapEntity], ParamConverter) and API Platform (the resource is resolved as a controller argument).

Option 2 — AlertEnrichableInterface on the exception

When the exception itself carries business data, implement this interface. Its attributes override those from AlertTrackableInterface for the same key.

use Vigil\OtelAlertBundle\AlertEnrichableInterface;

class BookCreationException extends \RuntimeException implements AlertEnrichableInterface
{
    public function __construct(string $reason, private readonly array $bookData = [])
    {
        parent::__construct('Book creation failed: ' . $reason);
    }

    public function getAlertAttributes(): array
    {
        return [
            'book.title'  => $this->bookData['title'] ?? 'unknown',
            'book.author' => $this->bookData['author'] ?? 'unknown',
        ];
    }
}

Priority order

AlertTrackableInterface  <  AlertContextHolder::set()  <  AlertEnrichableInterface
      (base layer)              (manual override)              (highest priority)

Usage by example

Example 1 — 404 exception with forced_exceptions

Context: a missing resource should return HTTP 404 to the client and generate an alert — because this ID should never be absent in production (data corruption, sync bug, etc.).

By default, the bundle ignores exceptions whose HTTP status is below the threshold (http_status_threshold: 500). To capture a specific exception regardless, add it to forced_exceptions.

The exception:

// src/Exception/BookNotFoundException.php
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class BookNotFoundException extends \RuntimeException implements HttpExceptionInterface
{
    public function __construct(int $id)
    {
        parent::__construct(sprintf('Book #%d not found.', $id));
    }

    public function getStatusCode(): int { return 404; }
    public function getHeaders(): array { return []; }
}

The configuration:

# config/packages/otel_alert.yaml
otel_alert:
    http_status_threshold: 500

    forced_exceptions:
        - App\Exception\BookNotFoundException   # captured even as a 404

What the bundle does:

  • ForcedExceptionsRule (priority 90) returns CAPTURE before HttpStatusThresholdRule can ignore the 404
  • Sends webhook + email + Jira according to your config
  • Marks the OTel span as error (STATUS_ERROR) in your supervision tool

Verify:

php bin/console otel:alert:test --exception="App\Exception\BookNotFoundException" --status=404

Example 2 — Business exception with contextual data

Context: a book creation attempt fails for a business reason. You want the alert to contain the title, author, and page count so the developer understands immediately what happened — without digging through logs.

The exception:

// src/Exception/BookCreationException.php
use Vigil\OtelAlertBundle\AlertEnrichableInterface;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class BookCreationException extends \RuntimeException
    implements HttpExceptionInterface, AlertEnrichableInterface
{
    public function __construct(string $reason, private readonly array $bookData = [])
    {
        parent::__construct(sprintf('Book creation failed: %s', $reason));
    }

    public function getAlertAttributes(): array
    {
        return [
            'book.title'  => $this->bookData['title'] ?? 'unknown',
            'book.author' => $this->bookData['author'] ?? 'unknown',
            'book.pages'  => $this->bookData['pages'] ?? 0,
        ];
    }

    public function getStatusCode(): int { return 422; }
    public function getHeaders(): array { return []; }
}

The controller:

#[Route('/books', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
    $data = $request->toArray();
    throw new BookCreationException('title already exists', $data);
}

The configuration:

otel_alert:
    forced_exceptions:
        - App\Exception\BookCreationException   # 422 < threshold 500, so forced

Webhook payload extract:

{
  "alert": {
    "exception": { "type": "App\\Exception\\BookCreationException" },
    "extra": {
      "book.title": "Le Petit Prince",
      "book.author": "Saint-Exupéry",
      "book.pages": 96
    }
  }
}

Example 3 — Custom voter-style rule

Context: BookCreationException is in forced_exceptions, so it always generates an alert. But at night (between 23:00 and 06:00), nobody will handle it — you want to silence it to avoid noise.

The rule system works exactly like Symfony voters:

Symfony VotersBundle rules
supports(string $attribute, mixed $subject)supports(AlertContext $context)
voteOnAttribute(...) → GRANTED / DENIED / ABSTAINevaluate(...) → CAPTURE / IGNORE / NEXT
Priority via tagsPriority via getPriority()
First voter that decides = final decisionFirst rule returning CAPTURE or IGNORE = final decision

The rule:

// src/OtelRule/OffHoursBookRule.php
use Vigil\OtelAlertBundle\Model\AlertContext;
use Vigil\OtelAlertBundle\Model\RuleResult;
use Vigil\OtelAlertBundle\Rule\AlertRuleInterface;

final class OffHoursBookRule implements AlertRuleInterface
{
    public static function getPriority(): int
    {
        // 95 > ForcedExceptionsRule (90): evaluated BEFORE forced_exceptions,
        // so it can silence even a forced exception.
        return 95;
    }

    public function supports(AlertContext $context): bool
    {
        return $context->getThrowable() instanceof BookCreationException;
    }

    public function evaluate(AlertContext $context): RuleResult
    {
        $hour = (int) (new \DateTimeImmutable())->format('G');

        return ($hour >= 23 || $hour < 6)
            ? RuleResult::IGNORE
            : RuleResult::NEXT;
    }

    public function getDescription(): string
    {
        return 'IGNORE BookCreationException between 23:00 and 06:00';
    }
}

No additional configuration needed. Any class implementing AlertRuleInterface is automatically tagged and injected into the rules engine.

Verify:

php bin/console otel:rules:list
 Priority   Rule                    Description                                    Source
  100       ExcludedExceptionsRule  IGNORE exceptions in excluded_exceptions        default
   95       OffHoursBookRule        IGNORE BookCreationException between 23h-6h     custom
   90       ForcedExceptionsRule    CAPTURE exceptions in forced_exceptions          default
   50       HttpStatusThresholdRule IGNORE if HTTP status < 500                     default
    0       DefaultCaptureRule      CAPTURE everything that reached this point       default

Priority matters:

PriorityBehaviour
OffHoursBookRule at 95Runs before ForcedExceptions — can silence even a forced exception
OffHoursBookRule at 85Runs after ForcedExceptions — CAPTURE already returned, this rule is never reached

Sensitive data masking

By default, the bundle automatically replaces the value of sensitive attributes with **** before they travel through the alert pipeline (rules engine, notifiers, span attributes). This means you can safely pass business objects to the context without worrying about leaking passwords, tokens, or card numbers into Jira tickets or webhook payloads.

Configuration

# config/packages/otel_alert.yaml
otel_alert:
    masking:
        enabled: true       # on by default
        mask: '****'        # replacement string
        keys: []            # extra keys to mask on top of the built-in list
        exclude_keys: []    # built-in keys to exclude from masking

Examples:

masking:
    keys:
        - user.tax_id       # mask a domain-specific attribute
        - order.promo_code  # mask a business field
    exclude_keys:
        - card_expiry       # keep card expiry visible (non-sensitive for your use case)

Built-in masked keys

The full list is defined in SensitiveDataMasker::BUILT_IN_KEYS and BUILT_IN_SUFFIXES. Any key ending with one of the built-in suffixes is also masked automatically.

CategoryExamples
Credentialspassword, passwd, secret, api_key, api_token, private_key, pin, otp
Tokensaccess_token, refresh_token, jwt, csrf_token, session, cookie
Payment (PCI-DSS)card_number, pan, cvv, cvc, iban, account_number
PII (GDPR)ssn, national_id, passport_number, date_of_birth, tax_id
Suffixesanything ending in _password, _token, _key, _secret, _pin, _cvv

How it works

Masking is applied in ExceptionListener before any attribute is written to the AlertContext. This means:

  • The rules engine never sees sensitive values
  • Notifiers (Jira, Webhook, Email) never receive them
  • OTel span attributes never contain them

Non-scalar values (arrays, objects) are always passed through unchanged.

Source code context in alerts

Every alert automatically includes the lines of code surrounding the exception, so you can understand what went wrong without opening your IDE.

What is included

ChannelContent
Webhookexception.source_context — JSON array [{line, code, is_error}]
JiraFile: /app/src/…/Service.php (line 42) + == Source context == block in description
Email"Fichier" row with path + line number, followed by a colour-coded code block (error line highlighted in red)

The bundle reads up to 5 lines before and 5 lines after the error line (11 lines maximum). If the file is unreadable (e.g. vendor path, eval'd code, stream wrapper), the field is silently omitted.

Service / project name resolution

service_name (used in webhook payload, email subject, and Jira ticket title) is resolved in this order:

  1. OTEL_SERVICE_NAME environment variable — standard OTel var, set in deployment.yaml or Docker
  2. APP_NAME environment variable — generic fallback
  3. composer.json → name field, part after the / (e.g. acme/my-api → my-api)
  4. 'unknown' — final fallback

This means projects that do not configure OTEL_SERVICE_NAME will automatically get a meaningful name from their composer.json.

Supervision deep links

Configure supervision.trace_url with a URL template to get a clickable link to the trace directly in your notifications. Use {trace_id} as a placeholder.

otel_alert:
    supervision:
        trace_url: 'https://grafana.mycompany.com/explore?schemaVersion=1&panes={"p1":{"datasource":"UID","queries":[{"refId":"A","query":"{trace_id}"}]}}&orgId=1'
Tooltrace_url value
Grafana Tempohttps://grafana.mycompany.com/explore?...&query={trace_id}...
Jaegerhttps://jaeger.mycompany.com/trace/{trace_id}
Datadoghttps://app.datadoghq.com/apm/traces?query=trace_id:{trace_id}
Zipkinhttps://zipkin.mycompany.com/zipkin/traces/{trace_id}
NotifierUsage
Webhooktrace.supervisor_url field in the JSON payload
JiraClickable link in the ticket description
EmailButton in the HTML body

The bundle builds the deep link locally by substituting {trace_id}. The span itself is exported independently by the OTel SDK. If the collector is unreachable, the link exists but resolves to nothing.

Manual instrumentation

Inject OtelInstrumentation in your services to instrument business code:

use Vigil\OtelAlertBundle\Instrumentation\OtelInstrumentation;
use Vigil\OtelAlertBundle\Model\AlertLevel;

final class LoanService
{
    public function __construct(private readonly OtelInstrumentation $otel) {}

    public function returnBook(Loan $loan): void
    {
        $span = $this->otel->startSpan('library.return_book', [
            'loan.id'   => $loan->getId(),
            'loan.book' => $loan->getBook()->getTitle(),
        ]);

        try {
            $this->penaltyService->calculate($loan);
        } catch (PenaltyException $e) {
            // Exception caught here — kernel.exception won't fire.
            // Trigger the full pipeline (Jira + email) manually:
            $this->otel->alert($e, AlertLevel::HIGH);
        } finally {
            $span->end();
        }
    }
}

Correlate logs with traces

$this->logger->error('Payment failed', [
    'trace_id' => $this->otel->getCurrentTraceId(),
]);

Adding a custom rule

When do you need one?

The built-in rules cover the most common cases, but some logic depends on your application's context:

  • Maintenance mode active: errors are expected, don't alert
  • Certain tenants should never generate alerts (QA, load-test accounts)
  • Some routes are known to be flaky during specific time windows
  • An exception that returns HTTP 200 needs alerting only under certain conditions

Rule decisions

  • RuleResult::CAPTURE — alert immediately, stop evaluating other rules
  • RuleResult::IGNORE — silence this exception, stop evaluating other rules
  • RuleResult::NEXT — no opinion, let the next rule decide

Built-in priorities

PriorityRule
100ExcludedExceptionsRule
90ForcedExceptionsRule
50HttpStatusThresholdRule
0DefaultCaptureRule

Example — Ignore during maintenance mode

final class IgnoreMaintenanceModeRule implements AlertRuleInterface
{
    public function __construct(private readonly MaintenanceService $maintenance) {}

    public static function getPriority(): int { return 75; }
    public function supports(AlertContext $context): bool { return true; }
    public function getDescription(): string { return 'IGNORE all exceptions during maintenance'; }

    public function evaluate(AlertContext $context): RuleResult
    {
        return $this->maintenance->isActive()
            ? RuleResult::IGNORE
            : RuleResult::NEXT;
    }
}

Registration

No services.yaml changes needed. Implement AlertRuleInterface and Symfony autoconfiguration handles the rest.

php bin/console otel:rules:list

Adding a custom notifier

use Vigil\OtelAlertBundle\Notifier\NotifierInterface;
use Vigil\OtelAlertBundle\Model\AlertContext;

final class PagerDutyNotifier implements NotifierInterface
{
    public function notify(AlertContext $context): AlertContext
    {
        // call PagerDuty API...
        return $context->withAttribute('pagerduty.incident_id', $incidentId);
    }
}

Notifiers can enrich the AlertContext — attributes added here are available to subsequent notifiers in the chain.

Overriding a built-in notifier

# config/services.yaml
Vigil\OtelAlertBundle\Notifier\Jira\JiraNotifier:
    tags: []  # removes otel_alert.notifier tag

App\Notifier\MyJiraNotifier:
    tags:
        - { name: 'otel_alert.notifier' }

CLI commands

# Interactive setup wizard
php bin/console otel:install

# Test the full pipeline without side effects
php bin/console otel:alert:test --dry-run
php bin/console otel:alert:test --exception="App\Exception\PaymentException" \
                                --route="/api/orders" --status=500 --dry-run

# Inspect registered rules (priority order)
php bin/console otel:rules:list

# Inspect active notifiers
php bin/console otel:notifiers:list

Kubernetes

OTEL_* variables must be declared in your deployment.yaml (not .env), because the OTel SDK initialises at PHP-FPM startup before Symfony loads its environment:

# deployment.yaml — PHP container env section
- name: OTEL_PHP_AUTOLOAD_ENABLED
  value: "true"
- name: OTEL_SERVICE_NAME
  value: "my-api-prod"
- name: OTEL_TRACES_EXPORTER
  value: "otlp"
- name: OTEL_METRICS_EXPORTER
  value: "otlp"
- name: OTEL_LOGS_EXPORTER
  value: "otlp"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
  value: "http://otel-collector:4318"
- name: OTEL_EXPORTER_OTLP_PROTOCOL
  value: "http/protobuf"
- name: OTEL_PROPAGATORS
  value: "tracecontext,baggage"
- name: OTEL_TRACES_SAMPLER
  value: "always_on"

Dockerfile

RUN apk add --no-cache --virtual .otel-build-deps $PHPIZE_DEPS \
    && pecl install opentelemetry \
    && docker-php-ext-enable opentelemetry \
    && echo "opentelemetry.autoload_enabled=On" \
        >> /usr/local/etc/php/conf.d/docker-php-ext-opentelemetry.ini \
    && apk del .otel-build-deps

PHP < 8.2 + ext-amqp:

RUN echo "opentelemetry.conflicts=amqp" \
    >> /usr/local/etc/php/conf.d/docker-php-ext-opentelemetry.ini

Jira credentials secret

kubectl create secret generic jira-credentials \
  --namespace=my-namespace \
  --from-literal=api_token=my-token \
  --from-literal=username=myemail@company.com
# deployment.yaml
- name: JIRA_API_TOKEN
  valueFrom:
    secretKeyRef:
      name: jira-credentials
      key: api_token
- name: JIRA_USERNAME
  valueFrom:
    secretKeyRef:
      name: jira-credentials
      key: username

Verify deployment

kubectl exec <pod> -c <container> -- env | grep OTEL
kubectl exec <pod> -c <container> -- php -m | grep opentelemetry
php bin/console otel:alert:test --dry-run

Configuration reference

Run php bin/console config:dump-reference otel_alert to dump the full reference with defaults.

Global

KeyTypeDefaultDescription
enabledbooltrueSet to false to disable the bundle entirely.
http_status_thresholdint500Minimum HTTP status code that triggers an alert.
deduplication_ttlint3600TTL in seconds to suppress duplicate Jira tickets. Requires symfony/cache.

Exception filtering

KeyTypeDefaultDescription
excluded_exceptionsstring[][AccessDeniedException, NotFoundHttpException, AccessDeniedHttpException]Classes never alerted. Uses instanceof.
forced_exceptionsstring[][]Classes always alerted regardless of HTTP status.

Async dispatch

KeyTypeDefaultDescription
async.modeenumautoauto / messenger / shutdown / sync
async.messenger_transportstringasyncMessenger transport name.
ModeBehaviour
autoUses Messenger if installed, falls back to shutdown. Recommended.
messengerVia symfony/messenger. Fails at boot if not installed.
shutdownCalls fastcgi_finish_request(), then notifies in a shutdown function.
syncBlocking — not recommended in production.

Supervision

KeyTypeDefaultDescription
supervision.trace_urlstring|nullnullURL template with {trace_id} placeholder.
supervision.export_tracesenumautoControls when OTel traces are exported. See below.

export_traces modes

ModeBehaviour
autoExported in prod, disabled in dev and test. Recommended default.
alwaysAlways exported, regardless of environment.
neverNever exported — useful to silence traces without removing the bundle.

PHP-FPM / Kubernetes limitation: the OTel SDK initialises at PHP-FPM startup, before Symfony boots. This setting only takes effect when using the Symfony dev server (no auto_prepend_file). In PHP-FPM or Kubernetes, control export via OTEL_TRACES_EXPORTER in your deployment.yaml or container environment instead.

Jira

KeyTypeDefaultDescription
jira.enabledboolfalseEnables the Jira notifier.
jira.hoststring—Jira Cloud base URL.
jira.usernamestring—Jira account email.
jira.api_tokenstring—Jira Cloud API token. Never commit.
jira.project_keystring—Target Jira project key.
jira.app_namestring|nullnullShown in ticket title: [ENV][app_name] Exception.
jira.issue_typestringBugJira issue type.
jira.default_prioritystringMediumDefault Jira priority.
jira.assigneestring|nullnullJira Cloud account ID (UUID).
jira.priorities.criticalstring[][]Exception classes mapped to priority Critical.
jira.priorities.highstring[][]Exception classes mapped to priority High.
jira.priorities.mediumstring[][]Exception classes mapped to priority Medium.
jira.priorities.lowstring[][]Exception classes mapped to priority Low.

Webhook

KeyTypeDefaultDescription
webhook.enabledboolfalseEnables the Webhook notifier.
webhook.urlstring—Target URL.
webhook.methodstringPOSTHTTP method.
webhook.headersmap{}Additional HTTP headers.
webhook.retry_countint3Retries on failure.
webhook.retry_delayint1000Delay between retries in milliseconds.
webhook.payload_templatestring|nullnullCustom JSON template with placeholders.

Default webhook payload

{
  "alert": {
    "timestamp": "2024-01-15T10:30:00+00:00",
    "exception": {
      "type": "App\\Exception\\PaymentException",
      "message": "Payment failed for order #1234",
      "file": "/app/src/Service/PaymentService.php",
      "line": 42,
      "source_context": [
        { "line": 39, "code": "    $order = $this->orderRepo->find($id);", "is_error": false },
        { "line": 40, "code": "    if ($order === null) {",                  "is_error": false },
        { "line": 41, "code": "        $this->logger->error('missing');",     "is_error": false },
        { "line": 42, "code": "        throw new PaymentException($id);",     "is_error": true  },
        { "line": 43, "code": "    }",                                        "is_error": false }
      ],
      "stack_trace": "#0 ..."
    },
    "request": { "route": "app_payment_process", "method": "POST", "status": 500 },
    "trace": { "id": "4bf92f3577b34da6a3ce929d0e0e4736", "supervisor_url": "https://..." },
    "context": { "environment": "prod", "service_name": "my-api", "hostname": "pod-abc123" },
    "extra": { "book.title": "Le Petit Prince" }
  }
}

Why use a template?

📖 Full documentation: docs/webhook_template_customization.md — all placeholders, examples (Slack, Teams, PagerDuty), tips and warnings. Validate your template with php bin/console otel:webhook:validate.

By default the bundle sends a rich JSON payload (see above). Use payload_template when the receiving endpoint imposes its own format — a Slack incoming webhook, a PagerDuty Events API, a custom internal tool — and you cannot change it.

Without a template the endpoint receives the full alert object. With a template you control exactly what is sent, field by field.

How to configure

payload_template must be a valid JSON string with placeholders. Write it inline in YAML using a block scalar:

# config/packages/otel_alert.yaml
otel_alert:
    webhook:
        enabled: true
        url: '%env(SLACK_WEBHOOK_URL)%'
        payload_template: |
            {
                "text": ":rotating_light: *[{alert.environment}] {exception.type}*",
                "attachments": [{
                    "color": "danger",
                    "fields": [
                        { "title": "Message",  "value": "{exception.message}", "short": false },
                        { "title": "Route",    "value": "{alert.route}",       "short": true  },
                        { "title": "Service",  "value": "{alert.service}",     "short": true  },
                        { "title": "Order ID", "value": "{extra.order_id}",    "short": true  },
                        { "title": "Trace",    "value": "{trace.supervisor_url}", "short": false }
                    ]
                }]
            }

Placeholder values are automatically escaped before substitution — quotes, backslashes, newlines and other special characters are handled correctly. If the template still produces invalid JSON (e.g. a structural error in your template), the bundle falls back to the default payload automatically.

Template placeholders

PlaceholderValue
{exception.type}Fully qualified exception class
{exception.message}Exception message
{exception.file}File path
{exception.line}Line number
{trace.id}OTel trace ID
{span.id}OTel span ID
{trace.supervisor_url}Deep link to supervision tool
{alert.route}Symfony route name
{alert.method}HTTP method
{alert.status}HTTP status code
{alert.environment}APP_ENV value
{alert.service}OTEL_SERVICE_NAME value
{server.hostname}Server/pod hostname
{timestamp}ISO 8601 timestamp
{extra.*}Any attribute set via AlertContextHolder::set('order_id', 42) → {extra.order_id}

Email

KeyTypeDefaultDescription
email.enabledboolfalseRequires symfony/mailer and a configured MAILER_DSN.
email.fromstring—Sender address.
email.contactsstring[][]Recipient addresses.

AMQP

KeyTypeDefaultDescription
amqp.auto_enablebooltrueAuto-enables OTel AMQP instrumentation when PHP >= 8.2 and ext-amqp are available.

PHP < 8.2 + ext-amqp: the OTel SDK uses PHP Fibers for context propagation. On PHP < 8.2, ext-amqp conflicts with Fiber internals, causing segfaults or silent trace loss. The bundle emits a warning and skips AMQP instrumentation automatically.

License

MIT

Français

Bundle Symfony qui enrichit l'instrumentation OpenTelemetry avec la capture automatique d'exceptions, des règles de filtrage configurables, et des notifications multi-canaux (Jira, Webhook, Email).

Table des matières

Compatibilité

PHPSymfony 6.1Symfony 7.xSymfony 8.xInstrumentation AMQP
8.1✅✅✅❌ nécessite PHP 8.2+
8.2âś…âś…âś…âś…
8.3âś…âś…âś…âś…

Installation

1. Installer le bundle

composer require vigil/otel-alert-bundle

Symfony Flex enregistre automatiquement le bundle dans config/bundles.php.

2. Lancer le wizard d'installation

Étape obligatoire — ne pas sauter. Le bundle nécessite un fichier de configuration et des packages OTel qui ne sont pas installés automatiquement. Le wizard s'en charge en une seule commande.

php bin/console otel:install

Le wizard :

  • installe les packages OTel requis via Composer
  • dĂ©tecte Messenger, AMQP et votre version PHP
  • demande quels notifiers activer (Jira, Webhook, Email) et collecte leurs paramètres
  • gĂ©nère config/packages/otel_alert.yaml et ajoute les credentials dans .env.local
  • affiche les instructions de dĂ©ploiement Kubernetes/Docker prĂŞtes Ă  copier

3. Configuration minimale manuelle

# config/packages/otel_alert.yaml
otel_alert:
    enabled: true

    jira:
        enabled: true
        app_name: my-api
        host: '%env(JIRA_HOST)%'
        username: '%env(JIRA_USERNAME)%'
        api_token: '%env(JIRA_API_TOKEN)%'
        project_key: MONPROJET

    webhook:
        enabled: true
        url: '%env(WEBHOOK_URL)%'

    email:
        enabled: true
        from: noreply@monapp.fr
        contacts:
            - dev@monapp.fr
# .env.local
JIRA_HOST=https://masociete.atlassian.net
JIRA_USERNAME=monemail@societe.com
JIRA_API_TOKEN=          # Token API Jira Cloud
WEBHOOK_URL=https://hooks.slack.com/services/...

Important — déploiements Kubernetes : Les variables OTEL_* doivent être définies dans votre deployment.yaml, pas dans .env. Le SDK OTel PHP s'initialise au démarrage de PHP-FPM, avant que Symfony charge .env. Voir la section Kubernetes ci-dessous.

Fonctionnement

Requête HTTP → exception levée
        │
        â–Ľ
ExceptionListener (kernel.exception, priorité -100)
        │
        ├── AlertContext::fromRequest()      construit le contexte immuable (route, status, traceId…)
        ├── AlertContextHolder::all()        injecte les attributs métier définis avant l'exception
        ├── AlertEnrichableInterface         lit les attributs métier portés par l'exception
        ├── AlertRulesEngine::evaluate()     décide CAPTURE ou IGNORE
        ├── enrichCurrentSpan()              marque le span OTel en erreur dans Grafana Tempo
        └── AlertDispatcher::dispatch()      envoie sans bloquer la réponse HTTP
                │
                └── NotifierChain
                        ├── JiraNotifier    crée un ticket via REST API v3
                        ├── WebhookNotifier POST vers n'importe quel endpoint
                        └── EmailNotifier   envoie un email HTML aux contacts configurés

Contexte métier dans les alertes

Par défaut, quand une exception se produit, le bundle connaît la route, le statut HTTP et le trace ID — mais pas l'objet métier en cours de traitement (le Book, Order, ou User). Deux mécanismes permettent d'ajouter ce contexte.

Option 1 — AlertTrackableInterface (recommandée)

Implémentez l'interface sur votre entité. Le bundle la tracke automatiquement dès qu'elle apparaît comme argument de controller — aucun code manuel requis dans vos controllers ou services.

// src/Entity/Book.php
use Vigil\OtelAlertBundle\AlertTrackableInterface;

class Book implements AlertTrackableInterface
{
    public function toAlertContext(): array
    {
        return [
            'book.id'     => $this->id,
            'book.title'  => $this->title,
            'book.status' => $this->status,
        ];
    }
}
// Controller — aucune mention du bundle
#[Route('/books/{id}/publish', methods: ['POST'])]
public function publish(Book $book): JsonResponse
{
    $this->publisher->publish($book); // tracké automatiquement
    return $this->json(['ok']);
}

toAlertContext() est appelé au moment de l'exception, pas au chargement de l'entité. Si votre service modifie $book->status avant que l'exception parte, l'alerte voit la valeur modifiée.

Fonctionne avec Symfony standard (#[MapEntity], ParamConverter) et API Platform (la resource est résolue comme argument de controller).

Option 2 — AlertEnrichableInterface sur l'exception

Quand l'exception porte elle-même des données métier, implémentez cette interface. Ses attributs écrasent ceux de AlertTrackableInterface en cas de clé identique.

use Vigil\OtelAlertBundle\AlertEnrichableInterface;

class BookCreationException extends \RuntimeException implements AlertEnrichableInterface
{
    public function __construct(string $reason, private readonly array $bookData = [])
    {
        parent::__construct('Création de livre échouée : ' . $reason);
    }

    public function getAlertAttributes(): array
    {
        return [
            'book.title'  => $this->bookData['title'] ?? 'inconnu',
            'book.author' => $this->bookData['author'] ?? 'inconnu',
        ];
    }
}

Ordre de priorité

AlertTrackableInterface  <  AlertContextHolder::set()  <  AlertEnrichableInterface
      (couche de base)         (surcharge manuelle)          (priorité maximale)

Utilisation par l'exemple

Exemple 1 — Exception 404 avec forced_exceptions

Contexte : une ressource introuvable doit retourner un HTTP 404 au client et générer une alerte — parce que cet ID ne devrait jamais manquer en production (donnée corrompue, bug de synchronisation, etc.).

Par défaut, le bundle ignore les exceptions dont le statut HTTP est inférieur au seuil (http_status_threshold: 500). Pour capturer malgré tout une exception spécifique, on l'ajoute à forced_exceptions.

L'exception :

// src/Exception/BookNotFoundException.php
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class BookNotFoundException extends \RuntimeException implements HttpExceptionInterface
{
    public function __construct(int $id)
    {
        parent::__construct(sprintf('Livre #%d introuvable.', $id));
    }

    public function getStatusCode(): int { return 404; }
    public function getHeaders(): array { return []; }
}

La configuration :

# config/packages/otel_alert.yaml
otel_alert:
    http_status_threshold: 500

    forced_exceptions:
        - App\Exception\BookNotFoundException   # capturée même en 404

Ce que fait le bundle :

  • ForcedExceptionsRule (prioritĂ© 90) retourne CAPTURE avant que HttpStatusThresholdRule n'ignore le 404
  • Envoie webhook + email + Jira selon la config
  • Marque le span OTel en rouge (STATUS_ERROR) dans votre outil de supervision

Vérification :

php bin/console otel:alert:test --exception="App\Exception\BookNotFoundException" --status=404

Exemple 2 — Exception métier avec données contextuelles

Contexte : une tentative de création de livre échoue pour une raison métier. On veut que l'alerte contienne le titre, l'auteur et le nombre de pages pour que le développeur comprenne immédiatement — sans fouiller les logs.

La solution — AlertEnrichableInterface : l'exception porte elle-même ses données contextuelles. Le bundle les détecte automatiquement, les ajoute au span OTel et au payload de chaque notifier. Zéro dépendance OTel dans le code applicatif.

L'exception :

// src/Exception/BookCreationException.php
use Vigil\OtelAlertBundle\AlertEnrichableInterface;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

class BookCreationException extends \RuntimeException
    implements HttpExceptionInterface, AlertEnrichableInterface
{
    public function __construct(string $reason, private readonly array $bookData = [])
    {
        parent::__construct(sprintf('Création de livre échouée : %s', $reason));
    }

    public function getAlertAttributes(): array
    {
        return [
            'book.title'  => $this->bookData['title'] ?? 'inconnu',
            'book.author' => $this->bookData['author'] ?? 'inconnu',
            'book.pages'  => $this->bookData['pages'] ?? 0,
        ];
    }

    public function getStatusCode(): int { return 422; }
    public function getHeaders(): array { return []; }
}

Le controller :

#[Route('/books', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
    $data = $request->toArray();
    throw new BookCreationException('titre déjà existant', $data);
}

La configuration :

otel_alert:
    forced_exceptions:
        - App\Exception\BookCreationException   # 422 < seuil 500, donc forcée

Extrait du payload webhook reçu :

{
  "alert": {
    "exception": { "type": "App\\Exception\\BookCreationException" },
    "extra": {
      "book.title": "Le Petit Prince",
      "book.author": "Saint-Exupéry",
      "book.pages": 96
    }
  }
}

Exemple 3 — Règle custom de type voter

Contexte : BookCreationException est dans forced_exceptions, donc elle génère toujours une alerte. Mais la nuit (entre 23h et 6h), personne ne traitera l'alerte — on préfère l'ignorer pour éviter le bruit.

Le système de règles fonctionne exactement comme les voters Symfony :

Voters SymfonyRègles du bundle
supports(string $attribute, mixed $subject)supports(AlertContext $context)
voteOnAttribute(...) → GRANTED / DENIED / ABSTAINevaluate(...) → CAPTURE / IGNORE / NEXT
Priorité via tagsPriorité via getPriority()
Premier voter qui tranche = décision finalePremière règle retournant CAPTURE ou IGNORE = décision finale

La règle :

// src/OtelRule/OffHoursBookRule.php
use Vigil\OtelAlertBundle\Model\AlertContext;
use Vigil\OtelAlertBundle\Model\RuleResult;
use Vigil\OtelAlertBundle\Rule\AlertRuleInterface;

final class OffHoursBookRule implements AlertRuleInterface
{
    public static function getPriority(): int
    {
        // 95 > ForcedExceptionsRule (90) : évaluée AVANT forced_exceptions,
        // peut donc ignorer même une exception forcée.
        return 95;
    }

    public function supports(AlertContext $context): bool
    {
        return $context->getThrowable() instanceof BookCreationException;
    }

    public function evaluate(AlertContext $context): RuleResult
    {
        $hour = (int) (new \DateTimeImmutable())->format('G');

        return ($hour >= 23 || $hour < 6)
            ? RuleResult::IGNORE
            : RuleResult::NEXT;
    }

    public function getDescription(): string
    {
        return 'IGNORE BookCreationException entre 23h et 6h';
    }
}

Aucune configuration supplémentaire. Toute classe implémentant AlertRuleInterface est automatiquement taguée et injectée dans le moteur de règles.

Vérification :

php bin/console otel:rules:list
 Priorité  Règle                   Description                                  Source
  100       ExcludedExceptionsRule  IGNORE les exceptions exclues                 défaut
   95       OffHoursBookRule        IGNORE BookCreationException entre 23h-6h     custom
   90       ForcedExceptionsRule    CAPTURE les exceptions forcées                défaut
   50       HttpStatusThresholdRule IGNORE si HTTP status < 500                   défaut
    0       DefaultCaptureRule      CAPTURE tout ce qui arrive ici                défaut

L'importance de la priorité :

PrioritéComportement
OffHoursBookRule à 95S'exécute avant ForcedExceptions → peut ignorer même une exception forcée
OffHoursBookRule à 85S'exécute après ForcedExceptions → CAPTURE déjà retourné, règle jamais atteinte

Masquage des données sensibles

Par défaut, le bundle remplace automatiquement la valeur des attributs sensibles par **** avant qu'ils ne circulent dans le pipeline d'alerte (moteur de règles, notifiers, attributs de span). Vous pouvez ainsi passer des objets métier au contexte sans risquer de laisser filtrer des mots de passe, tokens ou numéros de carte dans les tickets Jira ou les payloads webhook.

Configuration

# config/packages/otel_alert.yaml
otel_alert:
    masking:
        enabled: true       # activé par défaut
        mask: '****'        # chaîne de remplacement
        keys: []            # clés supplémentaires à masquer en plus de la liste built-in
        exclude_keys: []    # clés built-in à exclure du masquage

Exemples :

masking:
    keys:
        - user.tax_id       # masquer un attribut métier spécifique
        - order.promo_code
    exclude_keys:
        - card_expiry       # garder la date d'expiration visible (non sensible dans votre contexte)

Clés masquées par défaut

La liste complète est définie dans SensitiveDataMasker::BUILT_IN_KEYS et BUILT_IN_SUFFIXES. Toute clé se terminant par l'un des suffixes built-in est également masquée automatiquement.

CatégorieExemples
Credentialspassword, passwd, secret, api_key, api_token, private_key, pin, otp
Tokensaccess_token, refresh_token, jwt, csrf_token, session, cookie
Paiement (PCI-DSS)card_number, pan, cvv, cvc, iban, account_number
Données personnelles (RGPD)ssn, national_id, passport_number, date_of_birth, tax_id
Suffixestout ce qui se termine par _password, _token, _key, _secret, _pin, _cvv

Fonctionnement

Le masquage est appliqué dans ExceptionListener avant qu'un attribut ne soit écrit dans l'AlertContext. Cela signifie :

  • Le moteur de règles ne voit jamais les valeurs sensibles
  • Les notifiers (Jira, Webhook, Email) ne les reçoivent jamais
  • Les attributs de span OTel n'en contiennent jamais

Les valeurs non scalaires (tableaux, objets) passent toujours telles quelles.

Contexte de code source dans les alertes

Chaque alerte inclut automatiquement les lignes de code autour de l'exception, pour comprendre ce qui s'est passé sans ouvrir l'IDE.

Ce qui est inclus

CanalContenu
Webhookexception.source_context — tableau JSON [{line, code, is_error}]
JiraFile: /app/src/…/Service.php (line 42) + section == Source context == dans la description
EmailLigne "Fichier" avec chemin + numéro de ligne, suivi d'un bloc de code coloré (ligne fautive surlignée en rouge)

Le bundle lit jusqu'à 5 lignes avant et 5 après la ligne d'erreur (11 lignes maximum). Si le fichier est inaccessible (chemin vendor, code eval'd, stream wrapper), le champ est simplement omis.

Résolution du nom de service / projet

service_name (utilisé dans le payload webhook, l'objet de l'email et le titre du ticket Jira) est résolu dans cet ordre :

  1. Variable OTEL_SERVICE_NAME — variable OTel standard, à définir dans deployment.yaml ou Docker
  2. Variable APP_NAME — fallback générique
  3. composer.json → champ name, partie après le / (ex : acme/my-api → my-api)
  4. 'unknown' — dernier recours

Ainsi, les projets qui n'ont pas configuré OTEL_SERVICE_NAME obtiennent automatiquement un nom lisible depuis leur composer.json.

Liens de supervision

Configurez supervision.trace_url avec un template d'URL pour obtenir un lien cliquable vers la trace directement dans vos notifications.

otel_alert:
    supervision:
        trace_url: 'https://grafana.masociete.com/explore?schemaVersion=1&panes={"p1":{"datasource":"UID","queries":[{"refId":"A","query":"{trace_id}"}]}}&orgId=1'
OutilValeur de trace_url
Grafana Tempohttps://grafana.masociete.com/explore?...&query={trace_id}...
Jaegerhttps://jaeger.masociete.com/trace/{trace_id}
Datadoghttps://app.datadoghq.com/apm/traces?query=trace_id:{trace_id}
Zipkinhttps://zipkin.masociete.com/zipkin/traces/{trace_id}
NotifierUtilisation
WebhookChamp trace.supervisor_url dans le payload JSON
JiraLien cliquable dans la description du ticket
EmailBouton dans le corps HTML

Le bundle construit le lien localement en substituant {trace_id}. Le span lui-même est exporté indépendamment par le SDK OTel. Si le collector est injoignable, le lien existe dans la notification mais ne résout rien.

Instrumentation manuelle

Injectez OtelInstrumentation dans vos services pour instrumenter du code métier :

use Vigil\OtelAlertBundle\Instrumentation\OtelInstrumentation;
use Vigil\OtelAlertBundle\Model\AlertLevel;

final class LoanService
{
    public function __construct(private readonly OtelInstrumentation $otel) {}

    public function returnBook(Loan $loan): void
    {
        $span = $this->otel->startSpan('library.return_book', [
            'loan.id'   => $loan->getId(),
            'loan.book' => $loan->getBook()->getTitle(),
        ]);

        try {
            $this->penaltyService->calculate($loan);
        } catch (PenaltyException $e) {
            // Exception catchée ici — kernel.exception ne se déclenchera pas.
            // Déclenche le pipeline complet (Jira + email) manuellement :
            $this->otel->alert($e, AlertLevel::HIGH);
        } finally {
            $span->end();
        }
    }
}

Corréler les logs avec les traces

$this->logger->error('Paiement échoué', [
    'trace_id' => $this->otel->getCurrentTraceId(),
]);

Ajouter une règle custom

Quand en a-t-on besoin ?

Les règles built-in couvrent les cas les plus courants, mais certaines logiques dépendent de votre contexte applicatif :

  • Mode maintenance actif : les erreurs sont attendues, pas d'alerte
  • Certains tenants ne doivent jamais gĂ©nĂ©rer d'alertes (comptes QA, load-test)
  • Certaines routes sont connues pour ĂŞtre instables sur une fenĂŞtre temporelle
  • Une exception qui retourne HTTP 200 doit alerter uniquement dans certaines conditions

Décisions d'une règle

  • RuleResult::CAPTURE — alerte immĂ©diatement, arrĂŞte l'Ă©valuation
  • RuleResult::IGNORE — ignore l'exception, arrĂŞte l'Ă©valuation
  • RuleResult::NEXT — pas d'opinion, passe Ă  la règle suivante

Priorités built-in

PrioritéRègle
100ExcludedExceptionsRule
90ForcedExceptionsRule
50HttpStatusThresholdRule
0DefaultCaptureRule

Exemple — Ignorer pendant la maintenance

final class IgnoreMaintenanceModeRule implements AlertRuleInterface
{
    public function __construct(private readonly MaintenanceService $maintenance) {}

    public static function getPriority(): int { return 75; }
    public function supports(AlertContext $context): bool { return true; }
    public function getDescription(): string { return 'IGNORE toutes les exceptions en mode maintenance'; }

    public function evaluate(AlertContext $context): RuleResult
    {
        return $this->maintenance->isActive()
            ? RuleResult::IGNORE
            : RuleResult::NEXT;
    }
}

Enregistrement

Aucune modification de services.yaml nécessaire. Implémentez AlertRuleInterface et l'autoconfiguration Symfony s'occupe du reste.

php bin/console otel:rules:list

Ajouter un notifier custom

use Vigil\OtelAlertBundle\Notifier\NotifierInterface;
use Vigil\OtelAlertBundle\Model\AlertContext;

final class PagerDutyNotifier implements NotifierInterface
{
    public function notify(AlertContext $context): AlertContext
    {
        // appel API PagerDuty...
        return $context->withAttribute('pagerduty.incident_id', $incidentId);
    }
}

Les notifiers peuvent enrichir l'AlertContext — les attributs ajoutés sont disponibles pour les notifiers suivants dans la chaîne.

Surcharger un notifier built-in

# config/services.yaml
Vigil\OtelAlertBundle\Notifier\Jira\JiraNotifier:
    tags: []  # retire le tag otel_alert.notifier

App\Notifier\MonJiraNotifier:
    tags:
        - { name: 'otel_alert.notifier' }

Commandes CLI

# Wizard d'installation interactif
php bin/console otel:install

# Tester le pipeline complet sans effets de bord
php bin/console otel:alert:test --dry-run
php bin/console otel:alert:test --exception="App\Exception\PaymentException" \
                                --route="/api/orders" --status=500 --dry-run

# Inspecter les règles enregistrées (ordre de priorité)
php bin/console otel:rules:list

# Inspecter les notifiers actifs
php bin/console otel:notifiers:list

Kubernetes

Les variables OTEL_* doivent être déclarées dans votre deployment.yaml (pas dans .env), car le SDK OTel s'initialise au démarrage de PHP-FPM, avant que Symfony charge ses variables d'environnement :

# deployment.yaml — section env du container PHP
- name: OTEL_PHP_AUTOLOAD_ENABLED
  value: "true"
- name: OTEL_SERVICE_NAME
  value: "my-api-prod"
- name: OTEL_TRACES_EXPORTER
  value: "otlp"
- name: OTEL_METRICS_EXPORTER
  value: "otlp"
- name: OTEL_LOGS_EXPORTER
  value: "otlp"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
  value: "http://otel-collector:4318"
- name: OTEL_EXPORTER_OTLP_PROTOCOL
  value: "http/protobuf"
- name: OTEL_PROPAGATORS
  value: "tracecontext,baggage"
- name: OTEL_TRACES_SAMPLER
  value: "always_on"

Dockerfile

RUN apk add --no-cache --virtual .otel-build-deps $PHPIZE_DEPS \
    && pecl install opentelemetry \
    && docker-php-ext-enable opentelemetry \
    && echo "opentelemetry.autoload_enabled=On" \
        >> /usr/local/etc/php/conf.d/docker-php-ext-opentelemetry.ini \
    && apk del .otel-build-deps

PHP < 8.2 + ext-amqp :

RUN echo "opentelemetry.conflicts=amqp" \
    >> /usr/local/etc/php/conf.d/docker-php-ext-opentelemetry.ini

Secret Jira

kubectl create secret generic jira-credentials \
  --namespace=mon-namespace \
  --from-literal=api_token=mon-token \
  --from-literal=username=monemail@societe.com
# deployment.yaml
- name: JIRA_API_TOKEN
  valueFrom:
    secretKeyRef:
      name: jira-credentials
      key: api_token
- name: JIRA_USERNAME
  valueFrom:
    secretKeyRef:
      name: jira-credentials
      key: username

Vérification du déploiement

kubectl exec <pod> -c <container> -- env | grep OTEL
kubectl exec <pod> -c <container> -- php -m | grep opentelemetry
php bin/console otel:alert:test --dry-run

Référence de configuration

Exécutez php bin/console config:dump-reference otel_alert pour afficher la référence complète avec les valeurs par défaut.

Global

CléTypeDéfautDescription
enabledbooltrueMettre à false pour désactiver entièrement le bundle.
http_status_thresholdint500Statut HTTP minimum déclenchant une alerte.
deduplication_ttlint3600TTL en secondes pour supprimer les tickets Jira dupliqués. Nécessite symfony/cache.

Filtrage des exceptions

CléTypeDéfautDescription
excluded_exceptionsstring[][AccessDeniedException, NotFoundHttpException, AccessDeniedHttpException]Classes jamais alertées. Utilise instanceof.
forced_exceptionsstring[][]Classes toujours alertées quel que soit le statut HTTP.

Dispatch asynchrone

CléTypeDéfautDescription
async.modeenumautoauto / messenger / shutdown / sync
async.messenger_transportstringasyncNom du transport Messenger.
ModeComportement
autoUtilise Messenger si installé, sinon shutdown. Recommandé.
messengerVia symfony/messenger. Erreur au boot si non installé.
shutdownAppelle fastcgi_finish_request(), puis notifie en shutdown function.
syncBloquant — déconseillé en production.

Supervision

CléTypeDéfautDescription
supervision.trace_urlstring|nullnullTemplate d'URL avec le placeholder {trace_id}.
supervision.export_tracesenumautoContrĂ´le l'export des traces OTel. Voir ci-dessous.

Modes de export_traces

ModeComportement
autoExporté en prod, désactivé en dev et test. Défaut recommandé.
alwaysToujours exporté, quel que soit l'environnement.
neverJamais exporté — utile pour désactiver les traces sans retirer le bundle.

Limitation PHP-FPM / Kubernetes : le SDK OTel s'initialise au démarrage de PHP-FPM, avant que Symfony ne démarre. Ce paramètre n'est effectif qu'avec le serveur Symfony dev (sans auto_prepend_file). En PHP-FPM ou Kubernetes, contrôlez l'export via OTEL_TRACES_EXPORTER dans votre deployment.yaml ou l'environnement du container.

Jira

CléTypeDéfautDescription
jira.enabledboolfalseActive le notifier Jira.
jira.hoststring—URL de base Jira Cloud.
jira.usernamestring—Email du compte Jira.
jira.api_tokenstring—Token API Jira Cloud. Ne jamais committer.
jira.project_keystring—Clé du projet Jira cible.
jira.app_namestring|nullnullAffiché dans le titre du ticket : [ENV][app_name] Exception.
jira.issue_typestringBugType de ticket Jira.
jira.default_prioritystringMediumPriorité Jira par défaut.
jira.assigneestring|nullnullAccount ID Jira Cloud (UUID) du responsable par défaut.
jira.priorities.criticalstring[][]Classes d'exceptions mappées à la priorité Critical.
jira.priorities.highstring[][]Classes d'exceptions mappées à la priorité High.
jira.priorities.mediumstring[][]Classes d'exceptions mappées à la priorité Medium.
jira.priorities.lowstring[][]Classes d'exceptions mappées à la priorité Low.

Webhook

CléTypeDéfautDescription
webhook.enabledboolfalseActive le notifier Webhook.
webhook.urlstring—URL cible.
webhook.methodstringPOSTMéthode HTTP.
webhook.headersmap{}Headers HTTP supplémentaires.
webhook.retry_countint3Nombre de tentatives en cas d'échec.
webhook.retry_delayint1000Délai entre les tentatives en millisecondes.
webhook.payload_templatestring|nullnullTemplate JSON custom avec placeholders.

Payload webhook par défaut

{
  "alert": {
    "timestamp": "2024-01-15T10:30:00+00:00",
    "exception": {
      "type": "App\\Exception\\PaymentException",
      "message": "Paiement échoué pour la commande #1234",
      "file": "/app/src/Service/PaymentService.php",
      "line": 42,
      "source_context": [
        { "line": 39, "code": "    $order = $this->orderRepo->find($id);", "is_error": false },
        { "line": 40, "code": "    if ($order === null) {",                  "is_error": false },
        { "line": 41, "code": "        $this->logger->error('manquant');",   "is_error": false },
        { "line": 42, "code": "        throw new PaymentException($id);",     "is_error": true  },
        { "line": 43, "code": "    }",                                        "is_error": false }
      ],
      "stack_trace": "#0 ..."
    },
    "request": { "route": "app_payment_process", "method": "POST", "status": 500 },
    "trace": { "id": "4bf92f3577b34da6a3ce929d0e0e4736", "supervisor_url": "https://..." },
    "context": { "environment": "prod", "service_name": "my-api", "hostname": "pod-abc123" },
    "extra": { "book.title": "Le Petit Prince" }
  }
}

Pourquoi utiliser un template ?

📖 Documentation complète : docs/webhook_template_customization.md — tous les placeholders, exemples (Slack, Teams, PagerDuty), tips et points d'attention. Validez votre template avec php bin/console otel:webhook:validate.

Par défaut le bundle envoie un payload JSON riche (voir ci-dessus). Utilisez payload_template quand l'endpoint destinataire impose son propre format — un webhook Slack, l'API PagerDuty Events, un outil interne — et que vous ne pouvez pas le modifier.

Sans template, l'endpoint reçoit l'objet alert complet. Avec un template, vous contrôlez exactement ce qui est envoyé, champ par champ.

Comment configurer

payload_template doit être un JSON valide avec des placeholders. Écrivez-le en YAML avec un bloc scalaire :

# config/packages/otel_alert.yaml
otel_alert:
    webhook:
        enabled: true
        url: '%env(SLACK_WEBHOOK_URL)%'
        payload_template: |
            {
                "text": ":rotating_light: *[{alert.environment}] {exception.type}*",
                "attachments": [{
                    "color": "danger",
                    "fields": [
                        { "title": "Message",    "value": "{exception.message}", "short": false },
                        { "title": "Route",      "value": "{alert.route}",       "short": true  },
                        { "title": "Service",    "value": "{alert.service}",     "short": true  },
                        { "title": "N° commande","value": "{extra.order_id}",    "short": true  },
                        { "title": "Trace",      "value": "{trace.supervisor_url}", "short": false }
                    ]
                }]
            }

Si le template produit un JSON invalide après substitution des placeholders, le bundle bascule automatiquement sur le payload par défaut.

Placeholders du template

PlaceholderValeur
{exception.type}Classe de l'exception (FQCN)
{exception.message}Message de l'exception
{exception.file}Fichier source
{exception.line}Numéro de ligne
{trace.id}Trace ID OTel
{span.id}Span ID OTel
{trace.supervisor_url}Lien vers l'outil de supervision
{alert.route}Nom de la route Symfony
{alert.method}Méthode HTTP
{alert.status}Code de statut HTTP
{alert.environment}Valeur de APP_ENV
{alert.service}Valeur de OTEL_SERVICE_NAME
{server.hostname}Hostname du serveur/pod
{timestamp}Timestamp ISO 8601
{extra.*}N'importe quel attribut défini via AlertContextHolder::set('order_id', 42) → {extra.order_id}

Email

CléTypeDéfautDescription
email.enabledboolfalseNécessite symfony/mailer et un MAILER_DSN configuré.
email.fromstring—Adresse expéditeur.
email.contactsstring[][]Liste des adresses destinataires.

AMQP

CléTypeDéfautDescription
amqp.auto_enablebooltrueActive automatiquement l'instrumentation OTel AMQP quand PHP >= 8.2 et ext-amqp sont disponibles.

PHP < 8.2 + ext-amqp : le SDK OTel utilise les Fibers PHP pour propager le contexte de trace. Sur PHP < 8.2, ext-amqp entre en conflit avec les Fibers, provoquant des segfaults ou une perte silencieuse de données de trace. Le bundle émet un warning et ignore l'instrumentation AMQP automatiquement.

Licence

MIT