Ops AI PHP APM Configuration
Instrument your PHP application with OpenTelemetry so Ops AI can analyze runtime exceptions with full code context. The instrumentation captures exception types, stack traces, and function bodies, and recognizes library files to provide cleaner signals, allowing Ops AI to analyze issues and suggest fixes with accurate context.
What you get#
- Automatic error capture: exceptions + messages, stack traces with line numbers.
- Code context: function names and bodies, file paths and ranges, with library-file detection (
/vendor/,/pear/paths).
Prerequisites#
Step 1: Install the OpenTelemetry Extension#
Install the PECL extensions:
1pecl install opentelemetry protobufEnable the extension by adding to your php.ini:
1[opentelemetry]
2extension=opentelemetry.soVerify the installation:
1php -m | grep opentelemetryStep 2: Install Composer Packages#
1composer require \
2 open-telemetry/sdk \
3 open-telemetry/api \
4 open-telemetry/exporter-otlp \
5 google/protobufStep 3: Configure Environment Variables#
Set the following environment variables before starting your app:
1export OTEL_SERVICE_NAME="my-php-service"
2export OTEL_EXPORTER_OTLP_ENDPOINT="https://<YOUR_WORKSPACE>.middleware.io:443"
3export OTEL_EXPORTER_OTLP_HEADERS="authorization=<MW_API_KEY>"
4export OTEL_TRACES_EXPORTER="otlp"
5export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"Step 4: Capture Errors with Code Context#
Record each error with an explicit OTel span that includes structured stack details. This approach works regardless of whether auto-instrumentation creates a current span.
Call recordErrorOtel($e) wherever you catch an exception. It creates a dedicated span that carries the exception type, message, stacktrace, and structured exception.stack_details with the function body, file path, and line range for each frame.
1function recordErrorOtel(Throwable $e, string $errType = ''): void {
2 if (!class_exists('\OpenTelemetry\API\Trace\Span')) return;
3 if (!class_exists('\OpenTelemetry\API\Globals')) return;
4
5 try {
6 $stackTrace = $e->getTraceAsString();
7 $exceptionType = $errType ?: get_class($e);
8 $stackDetails = buildStackDetails($e);
9 $exceptionAttrs = [
10 'exception.type' => $exceptionType,
11 'exception.message' => $e->getMessage(),
12 'exception.stacktrace' => $stackTrace,
13 'exception.escaped' => true,
14 'exception.stack_details' => json_encode($stackDetails, JSON_UNESCAPED_SLASHES),
15 ];
16
17 $currentSpan = \OpenTelemetry\API\Trace\Span::getCurrent();
18 if ($currentSpan->getContext()->isValid()) {
19 $currentSpan->recordException($e, $exceptionAttrs);
20 $currentSpan->setStatus(\OpenTelemetry\API\Trace\StatusCode::STATUS_ERROR, $e->getMessage());
21 setErrorAttrs($currentSpan, $e, $exceptionType, $stackTrace);
22 }
23
24 $tracer = \OpenTelemetry\API\Globals::tracerProvider()->getTracer('my-php-service');
25 $errSpan = $tracer->spanBuilder("exception {$exceptionType}")
26 ->setSpanKind(\OpenTelemetry\API\Trace\SpanKind::KIND_INTERNAL)
27 ->startSpan();
28 $errSpan->recordException($e, $exceptionAttrs);
29 $errSpan->setStatus(\OpenTelemetry\API\Trace\StatusCode::STATUS_ERROR, $e->getMessage());
30 setErrorAttrs($errSpan, $e, $exceptionType, $stackTrace);
31 $errSpan->end();
32 } catch (Throwable $ignored) {
33 error_log("OTel recordError failed: " . $ignored->getMessage());
34 }
35}Build structured stack details#
Parses the top 10 frames from the exception trace into structured data. For application code, it extracts the function body around the error line:
1function buildStackDetails(Throwable $e): array {
2 $details = [];
3 $topFrame = ['file' => $e->getFile(), 'line' => $e->getLine(), 'function' => ''];
4 $frames = array_merge([$topFrame], $e->getTrace());
5
6 foreach (array_slice($frames, 0, 10) as $frame) {
7 $file = $frame['file'] ?? null;
8 $line = $frame['line'] ?? 0;
9 $func = $frame['function'] ?? '';
10 if ($file === null) continue;
11
12 $isExternal = str_contains($file, '/vendor/') || str_contains($file, '/pear/');
13
14 $detail = [
15 'exception.function_name' => $func,
16 'exception.file' => $file,
17 'exception.line' => $line,
18 'exception.is_file_external' => $isExternal,
19 'exception.language' => 'php',
20 ];
21
22 if (!$isExternal && is_readable($file)) {
23 $body = extractFunctionBody($file, $line, $func);
24 if ($body !== null) {
25 $detail['exception.start_line'] = $body['start_line'];
26 $detail['exception.end_line'] = $body['end_line'];
27 $detail['exception.function_body'] = $body['body'];
28 }
29 }
30
31 $details[] = $detail;
32 }
33
34 return $details;
35}Extract function body#
Walks backward from the error line to find the function start, walks forward counting braces to find the closing }, and returns the source code slice:
1function extractFunctionBody(string $file, int $errorLine, string $funcName): ?array {
2 try {
3 $lines = file($file, FILE_IGNORE_NEW_LINES);
4 if ($lines === false) return null;
5
6 $startIdx = null;
7 for ($i = $errorLine - 2; $i >= 0; $i--) {
8 if (preg_match('/^\s*(function |class )\b/', $lines[$i])) {
9 $startIdx = $i;
10 break;
11 }
12 }
13 if ($startIdx === null) $startIdx = max($errorLine - 6, 0);
14
15 $braceCount = 0;
16 $endIdx = null;
17 $foundOpen = false;
18 for ($i = $startIdx; $i < min($startIdx + 40, count($lines)); $i++) {
19 $braceCount += substr_count($lines[$i], '{') - substr_count($lines[$i], '}');
20 if ($braceCount > 0) $foundOpen = true;
21 if ($foundOpen && $braceCount <= 0) {
22 $endIdx = $i;
23 break;
24 }
25 }
26 if ($endIdx === null) $endIdx = min($errorLine + 4, count($lines) - 1);
27
28 $bodyLines = array_slice($lines, $startIdx, $endIdx - $startIdx + 1);
29
30 return [
31 'start_line' => $startIdx + 1,
32 'end_line' => $endIdx + 1,
33 'body' => implode("\n", $bodyLines) . "\n",
34 ];
35 } catch (Throwable $ignored) {
36 return null;
37 }
38}Set error attributes on the span#
Sets the attributes that Middleware uses for error detection:
1function setErrorAttrs($span, Throwable $e, string $exceptionType, string $stackTrace): void {
2 $span->setAttribute('error', true);
3 $span->setAttribute('otel.status_code', 'ERROR');
4 $span->setAttribute('otel.status_description', $e->getMessage());
5 $span->setAttribute('error.type', $exceptionType);
6 $span->setAttribute('error.message', $e->getMessage());
7 $span->setAttribute('error.stack', $stackTrace);
8 $span->setAttribute('error.source.file', $e->getFile());
9 $span->setAttribute('error.source.line', $e->getLine());
10 $span->setAttribute('http.status_code', 500);
11 $span->setAttribute('http.response.status_code', 500);
12
13 if ($e->getPrevious() !== null) {
14 $cause = $e->getPrevious();
15 $span->setAttribute('error.cause.type', get_class($cause));
16 $span->setAttribute('error.cause.message', $cause->getMessage());
17 }
18
19 $span->setAttribute('http.method', $_SERVER['REQUEST_METHOD'] ?? 'GET');
20 $span->setAttribute('http.url', $_SERVER['REQUEST_URI'] ?? '/');
21 $span->setAttribute('http.route', parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH));
22}Step 5: Use in Your Application#
In a route handler:
1try {
2 riskyOperation();
3} catch (Throwable $e) {
4 recordErrorOtel($e);
5 http_response_code(500);
6 echo json_encode(['error' => $e->getMessage()]);
7}In a global error handler:
1try {
2 routeRequest();
3} catch (Throwable $e) {
4 recordErrorOtel($e);
5 http_response_code(500);
6 echo json_encode(['error' => $e->getMessage()]);
7}With a custom error type label:
1try {
2 $data = json_decode($input, true, 512, JSON_THROW_ON_ERROR);
3} catch (JsonException $e) {
4 recordErrorOtel($e, 'ValidationError');
5 http_response_code(400);
6 echo json_encode(['error' => 'Invalid JSON']);
7}Validate & Troubleshoot#
- Extension installed? Run
php -m | grep opentelemetryand confirm it appears. - Composer packages present? Run
composer show | grep open-telemetryand confirm the SDK, API, and OTLP exporter are installed. - Env vars configured? Ensure
OTEL_SERVICE_NAME,OTEL_EXPORTER_OTLP_ENDPOINT, andOTEL_EXPORTER_OTLP_HEADERSare set. - Error capture working? Trigger a test error and confirm the exception event appears with
exception.stack_detailsin the Middleware APM UI.
Need assistance or want to learn more about Middleware? Get in touch with us via our Contact Us or join our Slack channel.