vigil / otel-alert-bundle
Symfony bundle enriching OpenTelemetry with exception capture, filtering, and notifications (Jira, Webhook)
Package info
gitlab.com/devapmailer/otel-alert-bundle
Type:symfony-bundle
pkg:composer/vigil/otel-alert-bundle
Requires
- php: >=8.1
- open-telemetry/opentelemetry-auto-symfony: ^1.1
- open-telemetry/sdk: ^1.13
- symfony/console: ^6.1 || ^7.0 || ^8.0
- symfony/framework-bundle: ^6.1 || ^7.0 || ^8.0
Requires (Dev)
- phpstan/phpstan: ^1.10
- phpstan/phpstan-symfony: ^1.3
- phpunit/phpunit: ^10.0
- symfony/cache: ^6.1 || ^7.0 || ^8.0
- symfony/http-client: ^6.1 || ^7.0 || ^8.0
- symfony/mailer: ^6.1 || ^7.0 || ^8.0
- symfony/messenger: ^6.1 || ^7.0 || ^8.0
- symfony/mime: ^6.1 || ^7.0 || ^8.0
- symfony/process: ^6.1 || ^7.0 || ^8.0
Suggests
- open-telemetry/opentelemetry-auto-amqp: AMQP instrumentation — requires PHP >= 8.2
- symfony/cache: Required for Jira deduplication
- symfony/http-client: Required for Jira and Webhook notifiers
- symfony/mailer: Required for Email notifier
- symfony/messenger: Required for async dispatch mode (MessengerAlertDispatcher)
- symfony/process: Required for otel:install wizard (runs composer require)
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
- Installation
- How it works
- Business context in alerts
- Usage by example
- Sensitive data masking
- Source code context in alerts
- Supervision deep links
- Manual instrumentation
- Adding a custom rule
- Adding a custom notifier
- Overriding a built-in notifier
- CLI commands
- Kubernetes
- Configuration reference
Compatibility
| PHP | Symfony 6.1 | Symfony 7.x | Symfony 8.x | AMQP 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.yamland 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 yourdeployment.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->statusbefore 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) returnsCAPTUREbeforeHttpStatusThresholdRulecan 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 Voters | Bundle rules |
|---|---|
supports(string $attribute, mixed $subject) | supports(AlertContext $context) |
voteOnAttribute(...) → GRANTED / DENIED / ABSTAIN | evaluate(...) → CAPTURE / IGNORE / NEXT |
| Priority via tags | Priority via getPriority() |
| First voter that decides = final decision | First 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
AlertRuleInterfaceis 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:
| Priority | Behaviour |
|---|---|
OffHoursBookRule at 95 | Runs before ForcedExceptions — can silence even a forced exception |
OffHoursBookRule at 85 | Runs 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.
| Category | Examples |
|---|---|
| Credentials | password, passwd, secret, api_key, api_token, private_key, pin, otp |
| Tokens | access_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 |
| Suffixes | anything 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
| Channel | Content |
|---|---|
| Webhook | exception.source_context — JSON array [{line, code, is_error}] |
| Jira | File: /app/src/…/Service.php (line 42) + == Source context == block in description |
| "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:
OTEL_SERVICE_NAMEenvironment variable — standard OTel var, set indeployment.yamlor DockerAPP_NAMEenvironment variable — generic fallbackcomposer.json→namefield, part after the/(e.g.acme/my-api→my-api)'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'
| Tool | trace_url value |
|---|---|
| Grafana Tempo | https://grafana.mycompany.com/explore?...&query={trace_id}... |
| Jaeger | https://jaeger.mycompany.com/trace/{trace_id} |
| Datadog | https://app.datadoghq.com/apm/traces?query=trace_id:{trace_id} |
| Zipkin | https://zipkin.mycompany.com/zipkin/traces/{trace_id} |
| Notifier | Usage |
|---|---|
| Webhook | trace.supervisor_url field in the JSON payload |
| Jira | Clickable link in the ticket description |
| Button 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 rulesRuleResult::IGNORE— silence this exception, stop evaluating other rulesRuleResult::NEXT— no opinion, let the next rule decide
Built-in priorities
| Priority | Rule |
|---|---|
| 100 | ExcludedExceptionsRule |
| 90 | ForcedExceptionsRule |
| 50 | HttpStatusThresholdRule |
| 0 | DefaultCaptureRule |
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_alertto dump the full reference with defaults.
Global
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Set to false to disable the bundle entirely. |
http_status_threshold | int | 500 | Minimum HTTP status code that triggers an alert. |
deduplication_ttl | int | 3600 | TTL in seconds to suppress duplicate Jira tickets. Requires symfony/cache. |
Exception filtering
| Key | Type | Default | Description |
|---|---|---|---|
excluded_exceptions | string[] | [AccessDeniedException, NotFoundHttpException, AccessDeniedHttpException] | Classes never alerted. Uses instanceof. |
forced_exceptions | string[] | [] | Classes always alerted regardless of HTTP status. |
Async dispatch
| Key | Type | Default | Description |
|---|---|---|---|
async.mode | enum | auto | auto / messenger / shutdown / sync |
async.messenger_transport | string | async | Messenger transport name. |
| Mode | Behaviour |
|---|---|
auto | Uses Messenger if installed, falls back to shutdown. Recommended. |
messenger | Via symfony/messenger. Fails at boot if not installed. |
shutdown | Calls fastcgi_finish_request(), then notifies in a shutdown function. |
sync | Blocking — not recommended in production. |
Supervision
| Key | Type | Default | Description |
|---|---|---|---|
supervision.trace_url | string|null | null | URL template with {trace_id} placeholder. |
supervision.export_traces | enum | auto | Controls when OTel traces are exported. See below. |
export_traces modes
| Mode | Behaviour |
|---|---|
auto | Exported in prod, disabled in dev and test. Recommended default. |
always | Always exported, regardless of environment. |
never | Never 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 viaOTEL_TRACES_EXPORTERin yourdeployment.yamlor container environment instead.
Jira
| Key | Type | Default | Description |
|---|---|---|---|
jira.enabled | bool | false | Enables the Jira notifier. |
jira.host | string | — | Jira Cloud base URL. |
jira.username | string | — | Jira account email. |
jira.api_token | string | — | Jira Cloud API token. Never commit. |
jira.project_key | string | — | Target Jira project key. |
jira.app_name | string|null | null | Shown in ticket title: [ENV][app_name] Exception. |
jira.issue_type | string | Bug | Jira issue type. |
jira.default_priority | string | Medium | Default Jira priority. |
jira.assignee | string|null | null | Jira Cloud account ID (UUID). |
jira.priorities.critical | string[] | [] | Exception classes mapped to priority Critical. |
jira.priorities.high | string[] | [] | Exception classes mapped to priority High. |
jira.priorities.medium | string[] | [] | Exception classes mapped to priority Medium. |
jira.priorities.low | string[] | [] | Exception classes mapped to priority Low. |
Webhook
| Key | Type | Default | Description |
|---|---|---|---|
webhook.enabled | bool | false | Enables the Webhook notifier. |
webhook.url | string | — | Target URL. |
webhook.method | string | POST | HTTP method. |
webhook.headers | map | {} | Additional HTTP headers. |
webhook.retry_count | int | 3 | Retries on failure. |
webhook.retry_delay | int | 1000 | Delay between retries in milliseconds. |
webhook.payload_template | string|null | null | Custom 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 withphp 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
| Placeholder | Value |
|---|---|
{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} |
| Key | Type | Default | Description |
|---|---|---|---|
email.enabled | bool | false | Requires symfony/mailer and a configured MAILER_DSN. |
email.from | string | — | Sender address. |
email.contacts | string[] | [] | Recipient addresses. |
AMQP
| Key | Type | Default | Description |
|---|---|---|---|
amqp.auto_enable | bool | true | Auto-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-amqpconflicts 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é
- Installation
- Fonctionnement
- Contexte métier dans les alertes
- Utilisation par l'exemple
- Masquage des données sensibles
- Contexte de code source dans les alertes
- Liens de supervision
- Instrumentation manuelle
- Ajouter une règle custom
- Ajouter un notifier custom
- Surcharger un notifier built-in
- Commandes CLI
- Kubernetes
- Référence de configuration
Compatibilité
| PHP | Symfony 6.1 | Symfony 7.x | Symfony 8.x | Instrumentation 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.yamlet 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 votredeployment.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->statusavant 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) retourneCAPTUREavant queHttpStatusThresholdRulen'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 Symfony | Règles du bundle |
|---|---|
supports(string $attribute, mixed $subject) | supports(AlertContext $context) |
voteOnAttribute(...) → GRANTED / DENIED / ABSTAIN | evaluate(...) → CAPTURE / IGNORE / NEXT |
| Priorité via tags | Priorité via getPriority() |
| Premier voter qui tranche = décision finale | Premiè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
AlertRuleInterfaceest 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 à 95 | S'exécute avant ForcedExceptions → peut ignorer même une exception forcée |
OffHoursBookRule à 85 | S'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égorie | Exemples |
|---|---|
| Credentials | password, passwd, secret, api_key, api_token, private_key, pin, otp |
| Tokens | access_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 |
| Suffixes | tout 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
| Canal | Contenu |
|---|---|
| Webhook | exception.source_context — tableau JSON [{line, code, is_error}] |
| Jira | File: /app/src/…/Service.php (line 42) + section == Source context == dans la description |
| Ligne "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 :
- Variable
OTEL_SERVICE_NAME— variable OTel standard, à définir dansdeployment.yamlou Docker - Variable
APP_NAME— fallback générique composer.json→ champname, partie après le/(ex :acme/my-api→my-api)'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'
| Outil | Valeur de trace_url |
|---|---|
| Grafana Tempo | https://grafana.masociete.com/explore?...&query={trace_id}... |
| Jaeger | https://jaeger.masociete.com/trace/{trace_id} |
| Datadog | https://app.datadoghq.com/apm/traces?query=trace_id:{trace_id} |
| Zipkin | https://zipkin.masociete.com/zipkin/traces/{trace_id} |
| Notifier | Utilisation |
|---|---|
| Webhook | Champ trace.supervisor_url dans le payload JSON |
| Jira | Lien cliquable dans la description du ticket |
| Bouton 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'évaluationRuleResult::IGNORE— ignore l'exception, arrête l'évaluationRuleResult::NEXT— pas d'opinion, passe à la règle suivante
Priorités built-in
| Priorité | Règle |
|---|---|
| 100 | ExcludedExceptionsRule |
| 90 | ForcedExceptionsRule |
| 50 | HttpStatusThresholdRule |
| 0 | DefaultCaptureRule |
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_alertpour afficher la référence complète avec les valeurs par défaut.
Global
| Clé | Type | Défaut | Description |
|---|---|---|---|
enabled | bool | true | Mettre à false pour désactiver entièrement le bundle. |
http_status_threshold | int | 500 | Statut HTTP minimum déclenchant une alerte. |
deduplication_ttl | int | 3600 | TTL en secondes pour supprimer les tickets Jira dupliqués. Nécessite symfony/cache. |
Filtrage des exceptions
| Clé | Type | Défaut | Description |
|---|---|---|---|
excluded_exceptions | string[] | [AccessDeniedException, NotFoundHttpException, AccessDeniedHttpException] | Classes jamais alertées. Utilise instanceof. |
forced_exceptions | string[] | [] | Classes toujours alertées quel que soit le statut HTTP. |
Dispatch asynchrone
| Clé | Type | Défaut | Description |
|---|---|---|---|
async.mode | enum | auto | auto / messenger / shutdown / sync |
async.messenger_transport | string | async | Nom du transport Messenger. |
| Mode | Comportement |
|---|---|
auto | Utilise Messenger si installé, sinon shutdown. Recommandé. |
messenger | Via symfony/messenger. Erreur au boot si non installé. |
shutdown | Appelle fastcgi_finish_request(), puis notifie en shutdown function. |
sync | Bloquant — déconseillé en production. |
Supervision
| Clé | Type | Défaut | Description |
|---|---|---|---|
supervision.trace_url | string|null | null | Template d'URL avec le placeholder {trace_id}. |
supervision.export_traces | enum | auto | ContrĂ´le l'export des traces OTel. Voir ci-dessous. |
Modes de export_traces
| Mode | Comportement |
|---|---|
auto | Exporté en prod, désactivé en dev et test. Défaut recommandé. |
always | Toujours exporté, quel que soit l'environnement. |
never | Jamais 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 viaOTEL_TRACES_EXPORTERdans votredeployment.yamlou l'environnement du container.
Jira
| Clé | Type | Défaut | Description |
|---|---|---|---|
jira.enabled | bool | false | Active le notifier Jira. |
jira.host | string | — | URL de base Jira Cloud. |
jira.username | string | — | Email du compte Jira. |
jira.api_token | string | — | Token API Jira Cloud. Ne jamais committer. |
jira.project_key | string | — | Clé du projet Jira cible. |
jira.app_name | string|null | null | Affiché dans le titre du ticket : [ENV][app_name] Exception. |
jira.issue_type | string | Bug | Type de ticket Jira. |
jira.default_priority | string | Medium | Priorité Jira par défaut. |
jira.assignee | string|null | null | Account ID Jira Cloud (UUID) du responsable par défaut. |
jira.priorities.critical | string[] | [] | Classes d'exceptions mappées à la priorité Critical. |
jira.priorities.high | string[] | [] | Classes d'exceptions mappées à la priorité High. |
jira.priorities.medium | string[] | [] | Classes d'exceptions mappées à la priorité Medium. |
jira.priorities.low | string[] | [] | Classes d'exceptions mappées à la priorité Low. |
Webhook
| Clé | Type | Défaut | Description |
|---|---|---|---|
webhook.enabled | bool | false | Active le notifier Webhook. |
webhook.url | string | — | URL cible. |
webhook.method | string | POST | Méthode HTTP. |
webhook.headers | map | {} | Headers HTTP supplémentaires. |
webhook.retry_count | int | 3 | Nombre de tentatives en cas d'échec. |
webhook.retry_delay | int | 1000 | Délai entre les tentatives en millisecondes. |
webhook.payload_template | string|null | null | Template 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 avecphp 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
| Placeholder | Valeur |
|---|---|
{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} |
| Clé | Type | Défaut | Description |
|---|---|---|---|
email.enabled | bool | false | Nécessite symfony/mailer et un MAILER_DSN configuré. |
email.from | string | — | Adresse expéditeur. |
email.contacts | string[] | [] | Liste des adresses destinataires. |
AMQP
| Clé | Type | Défaut | Description |
|---|---|---|---|
amqp.auto_enable | bool | true | Active 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-amqpentre 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