prod/php/ElasticOTel/InferredSpans/InferredSpans.php (282 lines of code) (raw):

<?php /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ /** @noinspection PhpIllegalPsrClassPathInspection */ declare(strict_types=1); namespace Elastic\OTel\InferredSpans; use Elastic\OTel\Util\ArrayUtil; use OpenTelemetry\API\Globals; use OpenTelemetry\API\Behavior\LogsMessagesTrait; use OpenTelemetry\API\Trace\SpanBuilderInterface; use OpenTelemetry\API\Trace\SpanInterface; use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\API\Trace\TracerInterface; use OpenTelemetry\Context\Context; use OpenTelemetry\Context\ContextStorageScopeInterface; use OpenTelemetry\SDK\Trace\Span; use OpenTelemetry\SemConv\TraceAttributes; use OpenTelemetry\API\Common\Time\Clock; use OpenTelemetry\SemConv\Version; use OpenTelemetry\Context\ContextInterface; use Throwable; use WeakReference; /** * @phpstan-type StackTraceFrameCallType '->'|'::' * @phpstan-type ExtendedStackTraceFrame array{function: string, line?: int, file?: string, class?: class-string, type?: StackTraceFrameCallType, span: WeakReference<SpanInterface>, * context: WeakReference<ContextInterface>, scope: WeakReference<ContextStorageScopeInterface>, stackTraceId: int} * @phpstan-type ExtendedStackTrace array<string|int, ExtendedStackTraceFrame> * @phpstan-type DebugBackTraceFrame array{function: string, line?: int, file?: string, class?: class-string, type?: StackTraceFrameCallType, args?: array<mixed>, object?: object} * @phpstan-type DebugBackTrace array<non-negative-int, DebugBackTraceFrame> */ class InferredSpans { use LogsMessagesTrait; public const IS_INFERRED_ATTRIBUTE_NAME = 'is_inferred'; private const METADATA_SPAN = 'span'; private const METADATA_CONTEXT = 'context'; private const METADATA_SCOPE = 'scope'; private const METADATA_STACKTRACE_ID = 'stackTraceId'; private const MILLIS_TO_NANOS = 1_000_000; private const FRAMES_TO_SKIP = 2; private TracerInterface $tracer; /** @var ExtendedStackTrace */ private array $lastStackTrace; private int $stackTraceId = 0; private bool $shutdown; public function __construct(private readonly bool $spanReductionEnabled, private readonly bool $attachStackTrace, private readonly float $minSpanDuration) { $this->tracer = Globals::tracerProvider()->getTracer( 'co.elastic.php.elastic-inferred-spans', null, Version::VERSION_1_30_0->url(), ); self::logDebug('spanReductionEnabled ' . $spanReductionEnabled . ' attachStackTrace ' . $attachStackTrace . ' minSpanDuration ' . $minSpanDuration); $this->lastStackTrace = array(); $this->shutdown = false; } // $durationMs - duration between interrupt request and interrupt occurrence public function captureStackTrace(int $durationMs, bool $topFrameIsInternalFunction): void { self::logDebug("captureStackTrace topFrameInternal: $topFrameIsInternalFunction, duration: $durationMs ms shutdown: " . $this->shutdown); if ($this->shutdown) { return; } try { /* @var DebugBackTrace $stackTrace */ $stackTrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); array_splice($stackTrace, 0, InferredSpans::FRAMES_TO_SKIP); // skip PhpFacade/Inferred spans logic frames $apmFramesFilteredOutCount = $this->filterOutAPMFrames($stackTrace); $this->compareStackTraces($stackTrace, $durationMs, $topFrameIsInternalFunction, $apmFramesFilteredOutCount); } catch (Throwable $throwable) { self::logError($throwable->__toString()); } } public function shutdown(): void { self::logDebug("shutdown"); $this->shutdown = true; $fakeTrace = []; $this->compareStackTraces($fakeTrace, 0, false, 0); } /** * @param DebugBackTrace $stackTrace */ private function compareStackTraces(array $stackTrace, int $durationMs, bool $topFrameIsInternalFunction, ?int $apmFramesFilteredOut): void { $this->stackTraceId++; $identicalFramesCount = $this->getHowManyStackFramesAreIdenticalFromStackBottom($stackTrace); self::logDebug("Same frames count: " . $identicalFramesCount); //, [$stackTrace, $this->lastStackTrace]); $lastStackTraceCount = count($this->lastStackTrace); $oldFramesCount = $lastStackTraceCount - $identicalFramesCount; // on previous stack trace - end all spans above identical frames $previousFrameStackTraceId = -1; $forceParentChangeFailed = false; for ($index = 0; $index < $oldFramesCount; $index++) { $endEpochNanos = null; // if last frame was internal function, so duration contains it's time, previous ones ended between sampling interval - they're shorter if ($topFrameIsInternalFunction) { $endEpochNanos = $this->getStartTime($durationMs); } $dropSpan = false; if ($this->spanReductionEnabled) { $dropSpan = $this->shouldReduceFrame($index, $oldFramesCount, $previousFrameStackTraceId, $forceParentChangeFailed); } $this->endFrameSpan($this->lastStackTrace[$index], $dropSpan, $endEpochNanos); unset($this->lastStackTrace[$index]); // remove ended frame } // reindex array $this->lastStackTrace = array_values($this->lastStackTrace); $stackTraceCount = count($stackTrace); if ($stackTraceCount == $identicalFramesCount) { // no frames to start return; } $first = true; // start spans for all frames below identical frames for ($index = $stackTraceCount - $identicalFramesCount - 1; $index >= 0; $index--) { if ($first && $apmFramesFilteredOut && !empty($this->lastStackTrace)) { self::logDebug("Going to start span in previous span context"); $newFrame = $this->startFrameSpan($stackTrace[$index], $durationMs, $this->lastStackTrace[0][self::METADATA_CONTEXT]->get(), $this->stackTraceId); } else { $newFrame = $this->startFrameSpan($stackTrace[$index], $durationMs, null, $this->stackTraceId); } $first = false; if ($this->attachStackTrace) { $newFrame[self::METADATA_SPAN]->get()?->setAttribute(TraceAttributes::CODE_STACKTRACE, $this->getStackTrace($this->lastStackTrace)); } if ($index == 0 && $topFrameIsInternalFunction) { /** @noinspection PhpRedundantOptionalArgumentInspection */ $this->endFrameSpan($newFrame, false, null); // we don't need to save the newest internal frame, it ended } else { array_unshift($this->lastStackTrace, $newFrame); // push-copy frame in front of last stack trace for next interruption processing } } } private function getStartTime(int $durationMs): int { return Clock::getDefault()->now() - $durationMs * self::MILLIS_TO_NANOS; } /** @param-out DebugBackTrace $stackTrace * @param DebugBackTrace $stackTrace * @return ?int */ private function filterOutAPMFrames(array &$stackTrace): ?int { // Filter out Elastic and Otel stack frames $cutIndex = null; for ($index = count($stackTrace) - 1; $index >= 0; $index--) { $frame = $stackTrace[$index]; if ( array_key_exists('class', $frame) && (str_starts_with($frame['class'], 'OpenTelemetry\\') || str_starts_with($frame['class'], 'Elastic\\')) ) { $cutIndex = $index; break; } } if ($cutIndex !== null) { array_splice($stackTrace, 0, $cutIndex + 1); } return $cutIndex; } /** @param DebugBackTrace $stackTrace */ private function getHowManyStackFramesAreIdenticalFromStackBottom(array $stackTrace): int { /** * Helper function to check if two frames are identical * * @phpstan-param DebugBackTraceFrame $frame1 * @phpstan-param DebugBackTraceFrame $frame2 */ $isSameFrame = function (array $frame1, array $frame2): bool { $keysToCompare = ['class', 'function', 'file', 'line', 'type']; foreach ($keysToCompare as $key) { if (($frame1[$key] ?? null) !== ($frame2[$key] ?? null)) { return false; } } return true; }; $stackTraceCount = count($stackTrace); $lastStackTraceCount = count($this->lastStackTrace); $count = min($stackTraceCount, $lastStackTraceCount); for ($index = 1; $index <= $count; $index++) { $stFrame = &$stackTrace[$stackTraceCount - $index]; $lastStFrame = &$this->lastStackTrace[$lastStackTraceCount - $index]; if (!$isSameFrame($stFrame, $lastStFrame)) { return $index - 1; } } return $count; } private function shouldReduceFrame(int $index, int $oldFramesCount, int &$previousFrameStackTraceId, bool &$forceParentChangeFailed): bool { $frameStackTraceId = $this->lastStackTrace[$index][self::METADATA_STACKTRACE_ID]; $dropSpan = $previousFrameStackTraceId == $frameStackTraceId; // if frame came from same stackTrace (interval) - we're dropping all spans above as they have same timing $previousFrameStackTraceId = $frameStackTraceId; if (!$dropSpan) { // if span should not be dropped, search for spans with same traceId and get parent from last one // find last span with same stackTraceId $lastSpanParent = null; for ($i = $index + 1; $i < $oldFramesCount; $i++) { if ($this->lastStackTrace[$i][self::METADATA_STACKTRACE_ID] != $frameStackTraceId) { break; } $span = $this->lastStackTrace[$i][self::METADATA_SPAN]->get(); if (!$span instanceof Span) { break; } $lastSpanParent = $span->getParentContext(); } if ($lastSpanParent) { $span = $this->lastStackTrace[$index][self::METADATA_SPAN]->get(); if (!$span instanceof Span) { return false; } self::logDebug( "Changing parent of span: '" . $span->getName() . "'", ['new', $lastSpanParent, 'old', $span->getParentContext()] ); $forceParentChangeFailed = !force_set_object_property_value($span, "parentSpanContext", $lastSpanParent); } } if ($forceParentChangeFailed && $dropSpan) { $dropSpan = false; } return $dropSpan; } /** * @phpstan-param ExtendedStackTraceFrame $frame */ private function shouldDropTooShortSpan(array $frame, ?int $endEpochNanos = null): bool { if ($this->minSpanDuration <= 0) { return false; } $span = $frame[self::METADATA_SPAN]->get(); if (!$span instanceof Span) { return false; } $duration = $endEpochNanos ? ($endEpochNanos - $span->getStartEpochNanos()) : $span->getDuration(); if ($duration < $this->minSpanDuration * self::MILLIS_TO_NANOS) { self::logDebug('Span ' . $span->getName() . ' duration ' . intval($duration / self::MILLIS_TO_NANOS) . 'ms is too short to fit within the minimum span duration limit: ' . $this->minSpanDuration . 'ms'); return true; } return false; } /** * @param ExtendedStackTrace $stackTrace */ private function getStackTrace(array $stackTrace): string { $id = 0; $str = ""; foreach ($stackTrace as $frame) { if (array_key_exists('file', $frame)) { $file = $frame['file'] . '(' . ($frame['line'] ?? '') . ')'; } else { $file = '[internal function]'; } $str .= sprintf("#%d %s: %s%s%s()\n", $id, $file, $frame['class'] ?? '', $frame['type'] ?? '', $frame['function']); $id++; } $str .= sprintf("#%d {main}\n", $id); return $str; } /** * @phpstan-param DebugBackTraceFrame $frame */ private static function setAttributeToFrameValue(array $frame, string $frameKey, SpanBuilderInterface $spanBuilder, string $attributeKey): void { if (ArrayUtil::getValueIfKeyExists($frameKey, $frame, /* out */ $frameValue) && (!empty($frameValue))) { $spanBuilder->setAttribute($attributeKey, $frameValue); } } /** * @phpstan-param DebugBackTraceFrame $frame * * @phpstan-return ExtendedStackTraceFrame */ private function startFrameSpan(array $frame, int $durationMs, ?ContextInterface $parentContext, int $stackTraceId): array { $parent = $parentContext ?? Context::getCurrent(); $spanName = (!empty($frame['class']) ? ($frame['class'] . '::') : '') . (!empty($frame['function']) ? $frame['function'] : '[unknown]'); $builder = $this->tracer->spanBuilder($spanName) ->setParent($parent) ->setStartTimestamp($this->getStartTime($durationMs)) ->setSpanKind(SpanKind::KIND_INTERNAL) ->setAttribute(self::IS_INFERRED_ATTRIBUTE_NAME, true); self::setAttributeToFrameValue($frame, 'function', $builder, TraceAttributes::CODE_FUNCTION_NAME); self::setAttributeToFrameValue($frame, 'class', $builder, TraceAttributes::CODE_NAMESPACE); self::setAttributeToFrameValue($frame, 'file', $builder, TraceAttributes::CODE_FILE_PATH); self::setAttributeToFrameValue($frame, 'line', $builder, TraceAttributes::CODE_LINE_NUMBER); $span = $builder->startSpan(); //OpenTelemetry\API\Trace\SpanInterface $context = $span->storeInContext($parent); //OpenTelemetry\Context\ContextInterface $scope = Context::storage()->attach($context); //OpenTelemetry\Context\ContextStorageScopeInterface $newFrame = $frame; $newFrame[self::METADATA_SPAN] = WeakReference::create($span); $newFrame[self::METADATA_CONTEXT] = WeakReference::create($context); $newFrame[self::METADATA_SCOPE] = WeakReference::create($scope); $newFrame[self::METADATA_STACKTRACE_ID] = $stackTraceId; self::logDebug("Span started: " . $newFrame['function'] . " parentContext: " . ($parentContext ? "custom" : "default") . " stackTraceId: " . $stackTraceId); return $newFrame; } /** * @phpstan-param ExtendedStackTraceFrame $frame */ private function endFrameSpan(array $frame, bool $dropSpan, ?int $endEpochNanos = null): void { if (!array_key_exists(self::METADATA_SPAN, $frame)) { // @phpstan-ignore function.alreadyNarrowedType self::logError("endFrameSpan missing metadata.", [$frame]); return; } if (!$dropSpan) { $dropSpan = $this->shouldDropTooShortSpan($frame, $endEpochNanos); } $span = $frame[self::METADATA_SPAN]->get(); if (!$span instanceof Span) { self::logDebug("Span in frame is not instanceof Trace\Span", [$span, $frame]); return; } if ($dropSpan) { self::logDebug("Span dropped: " . $span->getName() . ' StackTraceId: ' . $frame[self::METADATA_STACKTRACE_ID]); $frame[self::METADATA_SCOPE]->get()?->detach(); return; } $scope = Context::storage()->scope(); $scope?->detach(); if (!$scope || $scope->context() === Context::getCurrent()) { return; } $span->end($endEpochNanos); self::logDebug("Span finished: " . $span->getName() . ' StackTraceId: ' . $frame[self::METADATA_STACKTRACE_ID]); } }