Repository: elastic/apm-agent-php (php)

Issue:
Add support for ensureParentId API
For example [docs for .NET agent's `EnsureParentId` API](https://www.elastic.co/guide/en/apm/agent/dotnet/current/public-api.html#api-transaction-ensure-parent-id) 

Pull request description:
Implemented ensureParentId() API
Closes #412

Patch:
diff --git a/src/ElasticApm/Impl/Log/SinkBase.php b/src/ElasticApm/Impl/Log/SinkBase.php
index 033aae05..00842378 100644
--- a/src/ElasticApm/Impl/Log/SinkBase.php
+++ b/src/ElasticApm/Impl/Log/SinkBase.php
@@ -32,6 +32,7 @@ use Elastic\Apm\Impl\Util\TextUtil;
  */
 abstract class SinkBase implements SinkInterface
 {
+    /** @inheritDoc */
     public function consume(
         int $statementLevel,
         string $message,
diff --git a/src/ElasticApm/Impl/NoopTransaction.php b/src/ElasticApm/Impl/NoopTransaction.php
index f1af7ef4..e6a7cd86 100644
--- a/src/ElasticApm/Impl/NoopTransaction.php
+++ b/src/ElasticApm/Impl/NoopTransaction.php
@@ -95,4 +95,10 @@ final class NoopTransaction extends NoopExecutionSegment implements TransactionI
     {
         return NoopTransactionContext::singletonInstance();
     }
+
+    /** @inheritDoc */
+    public function ensureParentId(): string
+    {
+        return NoopExecutionSegment::ID;
+    }
 }
diff --git a/src/ElasticApm/Impl/Transaction.php b/src/ElasticApm/Impl/Transaction.php
index f1d08a55..44effa8c 100644
--- a/src/ElasticApm/Impl/Transaction.php
+++ b/src/ElasticApm/Impl/Transaction.php
@@ -311,6 +311,21 @@ final class Transaction extends ExecutionSegment implements TransactionInterface
         }
     }
 
+    /** @inheritDoc */
+    public function ensureParentId(): string
+    {
+        if ($this->data->parentId === null) {
+            $this->data->parentId = IdGenerator::generateId(Constants::EXECUTION_SEGMENT_ID_SIZE_IN_BYTES);
+            ($loggerProxy = $this->logger->ifDebugLevelEnabled(__LINE__, __FUNCTION__))
+            && $loggerProxy->log(
+                'Setting parent ID for already existing transaction',
+                ['parentId' => $this->data->parentId]
+            );
+        }
+
+        return $this->data->parentId;
+    }
+
     /** @inheritDoc */
     public function dispatchCreateError(?ErrorExceptionData $errorExceptionData): ?string
     {
diff --git a/src/ElasticApm/TransactionInterface.php b/src/ElasticApm/TransactionInterface.php
index a191d95f..4e547add 100644
--- a/src/ElasticApm/TransactionInterface.php
+++ b/src/ElasticApm/TransactionInterface.php
@@ -131,4 +131,13 @@ interface TransactionInterface extends ExecutionSegmentInterface
      * @see setResult() For the description
      */
     public function getResult(): ?string;
+
+    /**
+     * If the transaction does not have a parent ID yet,
+     * calling this method generates a new ID,
+     * sets it as the parent ID of this transaction, and returns it as a string.
+     *
+     * @return string
+     */
+    public function ensureParentId(): string;
 }


Test patch:
diff --git a/tests/ElasticApmTests/UnitTests/DistributedTracingTest.php b/tests/ElasticApmTests/UnitTests/DistributedTracingTest.php
index c096cd62..a3c5d724 100644
--- a/tests/ElasticApmTests/UnitTests/DistributedTracingTest.php
+++ b/tests/ElasticApmTests/UnitTests/DistributedTracingTest.php
@@ -26,8 +26,14 @@ declare(strict_types=1);
 namespace ElasticApmTests\UnitTests;
 
 use Elastic\Apm\ElasticApm;
+use Elastic\Apm\Impl\Config\OptionNames;
 use Elastic\Apm\Impl\GlobalTracerHolder;
+use Elastic\Apm\Impl\Util\TextUtil;
+use Elastic\Apm\TransactionInterface;
+use ElasticApmTests\UnitTests\Util\MockConfigRawSnapshotSource;
 use ElasticApmTests\UnitTests\Util\MockEventSink;
+use ElasticApmTests\UnitTests\Util\MockLogSink;
+use ElasticApmTests\UnitTests\Util\MockLogSinkStatement;
 use ElasticApmTests\UnitTests\Util\TracerUnitTestCaseBase;
 
 class DistributedTracingTest extends TracerUnitTestCaseBase
@@ -85,10 +91,12 @@ class DistributedTracingTest extends TracerUnitTestCaseBase
 
         // Assert
 
-        $idToTx = $this->mockEventSink->idToTransaction();
+        $actualTxIds = array_keys($this->mockEventSink->idToTransaction());
+        self::assertTrue(sort(/* ref */ $actualTxIds));
+        self::assertCount(2, $actualTxIds);
         $expectedTxIds = [$senderTransaction->getId(), $receiverTransaction->getId()];
-        self::assertCount(2, $idToTx);
-        self::assertEqualsCanonicalizing($expectedTxIds, array_keys($idToTx));
+        self::assertTrue(sort(/* ref */ $expectedTxIds));
+        self::assertEqualsCanonicalizing($expectedTxIds, $actualTxIds);
         $idToSpan = $this->mockEventSink->idToSpan();
         self::assertCount($isSentFromSpan ? 1 : 0, $idToSpan);
         $expectedParentId = $isSentFromSpan ? $senderSpan->getId() : $senderTransaction->getId();
@@ -203,4 +211,116 @@ class DistributedTracingTest extends TracerUnitTestCaseBase
             self::assertEmpty($receiverEventSink->idToSpan());
         }
     }
+
+    public function testEnsureParentId(): void
+    {
+        foreach ([false, true] as $isTracerEnabled) {
+            foreach ([false, true] as $doesTxHaveParentAlready) {
+                $this->implEnsureParentId(
+                    $isTracerEnabled,
+                    $doesTxHaveParentAlready
+                );
+            }
+        }
+    }
+
+    public function implEnsureParentId(bool $isTracerEnabled, bool $doesTxHaveParentAlready): void
+    {
+        // Arrange
+
+        $webFrontEventSink = new MockEventSink();
+        $webFrontTracer = self::buildTracerForTests($webFrontEventSink)->withEnabled($isTracerEnabled)->build();
+        $backendServiceLogSink = new MockLogSink();
+        $backendServiceEventSink = new MockEventSink();
+        $backendServiceTracer = self::buildTracerForTests($backendServiceEventSink)
+                                    ->withEnabled($isTracerEnabled)
+                                    ->withLogSink($backendServiceLogSink)
+                                    ->withConfigRawSnapshotSource(
+                                        (new MockConfigRawSnapshotSource())->set(OptionNames::LOG_LEVEL, 'DEBUG')
+                                    )->build();
+
+        // Act
+
+        /** @var ?TransactionInterface */
+        $webFrontTx = null;
+
+        /** @var ?string */
+        $distTracingData = null;
+
+        if ($doesTxHaveParentAlready) {
+            GlobalTracerHolder::set($webFrontTracer);
+            $webFrontTx = ElasticApm::beginCurrentTransaction('web front end TX', 'web front end TX type');
+            $distTracingData = ElasticApm::getSerializedCurrentDistributedTracingData();
+        }
+
+        GlobalTracerHolder::set($backendServiceTracer);
+        $backendServiceTx = ElasticApm::beginCurrentTransaction(
+            'backend service TX',
+            'backend service TX type',
+            /* timestamp: */ null,
+            $distTracingData
+        );
+        $parentId = ElasticApm::getCurrentTransaction()->ensureParentId();
+        $backendServiceTx->end();
+
+        if ($webFrontTx !== null) {
+            GlobalTracerHolder::set($webFrontTracer);
+            $webFrontTx->end();
+        }
+
+        // Assert
+
+        self::assertEmpty($this->mockEventSink->idToTransaction());
+        self::assertEmpty($this->mockEventSink->idToSpan());
+
+        self::assertEmpty($webFrontEventSink->idToSpan());
+        self::assertEmpty($backendServiceEventSink->idToSpan());
+
+        if (!$isTracerEnabled) {
+            self::assertEmpty($webFrontEventSink->idToTransaction());
+            self::assertEmpty($backendServiceEventSink->idToTransaction());
+            return;
+        }
+
+        self::assertCount(1, $backendServiceEventSink->idToTransaction());
+        self::assertSame($backendServiceTx->getId(), $backendServiceEventSink->singleTransaction()->id);
+
+        if ($doesTxHaveParentAlready) {
+            self::assertCount(1, $webFrontEventSink->idToTransaction());
+            self::assertNotNull($webFrontTx);
+            self::assertSame($webFrontTx->getId(), $webFrontEventSink->singleTransaction()->id);
+
+            self::assertSame(
+                $webFrontEventSink->singleTransaction()->id,
+                $backendServiceEventSink->singleTransaction()->parentId
+            );
+        } else {
+            self::assertEmpty($webFrontEventSink->idToTransaction());
+
+            self::assertSame($parentId, $backendServiceEventSink->singleTransaction()->parentId);
+
+            $logStatements = array_filter(
+                $backendServiceLogSink->consumed,
+                function (MockLogSinkStatement $logStatement): bool {
+                    return TextUtil::isSuffixOf('Transaction.php', $logStatement->srcCodeFile)
+                           && $logStatement->srcCodeFunc === 'ensureParentId';
+                }
+            );
+            $this->assertCount(1, $logStatements);
+            $logStatement = array_values($logStatements)[0];
+            $this->assertGreaterThanOrEqual(1, count($logStatement->contextsStack));
+            $ctxsWithParentId = array_filter(
+                $logStatement->contextsStack,
+                /**
+                 * @param array<string, mixed> $context
+                 */
+                function (array $context): bool {
+                    return array_key_exists('parentId', $context);
+                }
+            );
+            $this->assertCount(1, $ctxsWithParentId);
+            $ctxWithParentId = array_values($ctxsWithParentId)[0];
+            $this->assertSame($parentId, $ctxWithParentId['parentId']);
+        }
+    }
 }
diff --git a/tests/ElasticApmTests/UnitTests/Util/MockLogSink.php b/tests/ElasticApmTests/UnitTests/Util/MockLogSink.php
new file mode 100644
index 00000000..3f7b9cc1
--- /dev/null
+++ b/tests/ElasticApmTests/UnitTests/Util/MockLogSink.php
@@ -0,0 +1,57 @@
+<?php
+
+/*
+ * 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.
+ */
+
+declare(strict_types=1);
+
+namespace ElasticApmTests\UnitTests\Util;
+
+use Elastic\Apm\Impl\Log\SinkInterface;
+
+class MockLogSink implements SinkInterface
+{
+    /** @var MockLogSinkStatement[] */
+    public $consumed = [];
+
+    /** @inheritDoc */
+    public function consume(
+        int $statementLevel,
+        string $message,
+        array $contextsStack,
+        string $category,
+        string $srcCodeFile,
+        int $srcCodeLine,
+        string $srcCodeFunc,
+        ?bool $includeStacktrace,
+        int $numberOfStackFramesToSkip
+    ): void {
+        $this->consumed[] = new MockLogSinkStatement(
+            $statementLevel,
+            $message,
+            $contextsStack,
+            $category,
+            $srcCodeFile,
+            $srcCodeLine,
+            $srcCodeFunc,
+            $includeStacktrace,
+            $numberOfStackFramesToSkip
+        );
+    }
+}
diff --git a/tests/ElasticApmTests/UnitTests/Util/MockLogSinkStatement.php b/tests/ElasticApmTests/UnitTests/Util/MockLogSinkStatement.php
new file mode 100644
index 00000000..3b147bae
--- /dev/null
+++ b/tests/ElasticApmTests/UnitTests/Util/MockLogSinkStatement.php
@@ -0,0 +1,87 @@
+<?php
+
+/*
+ * 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.
+ */
+
+declare(strict_types=1);
+
+namespace ElasticApmTests\UnitTests\Util;
+
+class MockLogSinkStatement
+{
+    /** @var int */
+    public $statementLevel;
+
+    /** @var string */
+    public $message;
+
+    /** @var array<string, mixed>[] */
+    public $contextsStack;
+
+    /** @var string */
+    public $category;
+
+    /** @var string */
+    public $srcCodeFile;
+
+    /** @var int */
+    public $srcCodeLine;
+
+    /** @var string */
+    public $srcCodeFunc;
+
+    /** @var ?bool */
+    public $includeStacktrace;
+
+    /** @var int */
+    public $numberOfStackFramesToSkip;
+
+    /**
+     * @param int                    $statementLevel
+     * @param string                 $message
+     * @param array<string, mixed>[] $contextsStack
+     * @param string                 $category
+     * @param string                 $srcCodeFile
+     * @param int                    $srcCodeLine
+     * @param string                 $srcCodeFunc
+     * @param bool|null              $includeStacktrace
+     * @param int                    $numberOfStackFramesToSkip
+     */
+    public function __construct(
+        int $statementLevel,
+        string $message,
+        array $contextsStack,
+        string $category,
+        string $srcCodeFile,
+        int $srcCodeLine,
+        string $srcCodeFunc,
+        ?bool $includeStacktrace,
+        int $numberOfStackFramesToSkip
+    ) {
+        $this->statementLevel = $statementLevel;
+        $this->message = $message;
+        $this->contextsStack = $contextsStack;
+        $this->category = $category;
+        $this->srcCodeFile = $srcCodeFile;
+        $this->srcCodeLine = $srcCodeLine;
+        $this->srcCodeFunc = $srcCodeFunc;
+        $this->includeStacktrace = $includeStacktrace;
+        $this->numberOfStackFramesToSkip = $numberOfStackFramesToSkip;
+    }
+}