diff --git a/composer.json b/composer.json index d231477c6..8605380ff 100644 --- a/composer.json +++ b/composer.json @@ -76,7 +76,7 @@ "tests": "vendor/bin/phpunit --verbose", "cs-check": "vendor/bin/php-cs-fixer fix --verbose --diff --dry-run", "cs-fix": "vendor/bin/php-cs-fixer fix --verbose --diff", - "phpstan": "vendor/bin/phpstan analyse", + "phpstan": "vendor/bin/phpstan analyse --memory-limit=512M", "mago": "vendor/bin/mago --config=mago.toml analyze" }, "config": { diff --git a/src/Attributes/AttributeBag.php b/src/Attributes/AttributeBag.php index 0d5b3018b..b9f7226f8 100644 --- a/src/Attributes/AttributeBag.php +++ b/src/Attributes/AttributeBag.php @@ -30,6 +30,16 @@ public function set(string $key, $value): self return $this; } + /** + * @param mixed $value + */ + public function setUnlessNull(string $key, $value): void + { + if ($value !== null) { + self::set($key, $value); + } + } + public function get(string $key): ?Attribute { return $this->attributes[$key] ?? null; diff --git a/src/Serializer/EnvelopItems/TransactionItem.php b/src/Serializer/EnvelopItems/TransactionItem.php index 28baf99ea..f0b81d294 100644 --- a/src/Serializer/EnvelopItems/TransactionItem.php +++ b/src/Serializer/EnvelopItems/TransactionItem.php @@ -4,10 +4,13 @@ namespace Sentry\Serializer\EnvelopItems; +use Sentry\Attributes\Attribute; +use Sentry\Attributes\AttributeBag; use Sentry\Event; use Sentry\EventType; use Sentry\Serializer\Traits\BreadcrumbSeralizerTrait; use Sentry\Tracing\Span; +use Sentry\Tracing\SpanStatus; use Sentry\Tracing\TransactionMetadata; use Sentry\Util\JSON; @@ -28,6 +31,17 @@ class TransactionItem implements EnvelopeItemInterface public static function toEnvelopeItem(Event $event): string { + $transactionSpans = []; + $genAiSpans = []; + + foreach ($event->getSpans() as $span) { + if (strpos($span->getOp() ?? '', 'gen_ai.') === 0) { + $genAiSpans[] = $span; + } else { + $transactionSpans[] = $span; + } + } + $header = [ 'type' => (string) EventType::transaction(), 'content_type' => 'application/json', @@ -121,13 +135,29 @@ public static function toEnvelopeItem(Event $event): string $payload['request'] = $event->getRequest(); } - $payload['spans'] = array_values(array_map([self::class, 'serializeSpan'], $event->getSpans())); + $payload['spans'] = array_values(array_map([self::class, 'serializeSpan'], $transactionSpans)); $transactionMetadata = $event->getSdkMetadata('transaction_metadata'); if ($transactionMetadata instanceof TransactionMetadata) { $payload['transaction_info']['source'] = (string) $transactionMetadata->getSource(); } + if (\count($genAiSpans) > 0) { + $genAi = []; + $genAi['items'] = array_map(static function (Span $span) use ($event) { + return self::serializeSpanV2($span, $event); + }, $genAiSpans); + $genAi['version'] = 2; + + $genAiHeaders = [ + 'type' => 'span', + 'item_count' => \count($genAiSpans), + 'content_type' => 'application/vnd.sentry.items.span.v2+json', + ]; + + return \sprintf("%s\n%s\n%s\n%s", JSON::encode($header), JSON::encode($payload), JSON::encode($genAiHeaders), JSON::encode($genAi)); + } + return \sprintf("%s\n%s", JSON::encode($header), JSON::encode($payload)); } @@ -187,4 +217,78 @@ protected static function serializeSpan(Span $span): array return $result; } + + /** + * @return array + * + * @phpstan-return array{ + * trace_id: string, + * span_id: string, + * name: string|null, + * is_segment: false, + * start_timestamp: float, + * attributes: array, + * status: 'ok'|'error', + * end_timestamp?: float|null, + * parent_span_id?: string, + * } + */ + protected static function serializeSpanV2(Span $span, Event $event): array + { + $result = [ + 'trace_id' => (string) $span->getTraceId(), + 'span_id' => (string) $span->getSpanId(), + 'name' => $span->getDescription() ?? $span->getOp(), + 'is_segment' => false, + 'start_timestamp' => $span->getStartTimestamp(), + 'attributes' => array_map(static function (Attribute $value) { + return [ + 'type' => $value->getType(), + 'value' => $value->getValue(), + ]; + }, self::collectV2Attributes($span, $event)->all()), + 'status' => 'ok', + ]; + if ($span->getEndTimestamp() !== null) { + $result['end_timestamp'] = $span->getEndTimestamp(); + } + if ($span->getStatus() !== null) { + $result['status'] = $span->getStatus() === SpanStatus::ok() ? 'ok' : 'error'; + } + if ($span->getParentSpanId() !== null) { + $result['parent_span_id'] = (string) $span->getParentSpanId(); + } + + return $result; + } + + /*** + * @mago-ignore analysis:redundant-null-coalesce + * @mago-ignore analysis:mixed-assignment + */ + private static function collectV2Attributes(Span $span, Event $event): AttributeBag + { + $attributes = new AttributeBag(); + $attributes->setUnlessNull('sentry.op', $span->getOp()); + $attributes->set('sentry.origin', $span->getOrigin() ?? 'manual'); + $attributes->setUnlessNull('sentry.release', $event->getRelease()); + $attributes->setUnlessNull('sentry.environment', $event->getEnvironment()); + $attributes->setUnlessNull('server.address', $event->getServerName()); + $attributes->setUnlessNull('sentry.segment.name', $event->getTransaction()); + $attributes->set('sentry.sdk.name', $event->getSdkPayload()['name'] ?? null); + $attributes->set('sentry.sdk.version', $event->getSdkPayload()['version'] ?? null); + $attributes->set('sentry.segment.id', $event->getContexts()['trace']['span_id'] ?? null); + + foreach ($span->getTags() as $key => $value) { + $attributes->set($key, $value); + } + + foreach ($span->getData() as $key => $value) { + $attributes->set($key, $value); + } + + $attributes->forget('status'); + + return $attributes; + } } diff --git a/tests/Serializer/PayloadSerializerTest.php b/tests/Serializer/PayloadSerializerTest.php index 41e3968ec..6ce4b6749 100644 --- a/tests/Serializer/PayloadSerializerTest.php +++ b/tests/Serializer/PayloadSerializerTest.php @@ -311,6 +311,76 @@ public static function serializeAsEnvelopeDataProvider(): iterable , ]; + $regularSpan = new Span(); + $regularSpan->setSpanId(new SpanId('b01b9f6349558cd1')); + $regularSpan->setTraceId(new TraceId('21160e9b836d479f81611368b2aa3d2c')); + $regularSpan->setParentSpanId(new SpanId('5dd538dc297544cc')); + $regularSpan->setOp('http.client'); + $regularSpan->setDescription('GET https://api.example.com/models'); + $regularSpan->setStatus(SpanStatus::ok()); + $regularSpan->setStartTimestamp(1597790836); + $regularSpan->setData([ + 'url' => 'https://api.example.com/models', + 'method' => 'GET', + ]); + $regularSpan->setTags(['http.status_code' => '200']); + $regularSpan->finish(1597790836.25); + + $genAiSpan1 = new Span(); + $genAiSpan1->setSpanId(new SpanId('a01b9f6349558cd1')); + $genAiSpan1->setTraceId(new TraceId('21160e9b836d479f81611368b2aa3d2c')); + $genAiSpan1->setParentSpanId(new SpanId('b01b9f6349558cd1')); + $genAiSpan1->setOp('gen_ai.chat'); + $genAiSpan1->setDescription('chat.completions create'); + $genAiSpan1->setStatus(SpanStatus::ok()); + $genAiSpan1->setStartTimestamp(1597790836.5); + $genAiSpan1->setOrigin('auto.ai.openai'); + $genAiSpan1->setTags([ + 'ai.provider' => 'openai', + 'ai.operation' => 'chat', + ]); + $genAiSpan1->setData([ + 'gen_ai.request.model' => 'gpt-4o-mini', + 'gen_ai.response.streaming' => true, + 'gen_ai.usage.input_tokens' => 12, + 'gen_ai.request.temperature' => 0.7, + ]); + $genAiSpan1->finish(1597790837.25); + + $genAiSpan2 = new Span(); + $genAiSpan2->setSpanId(new SpanId('a01b9f6349558cd2')); + $genAiSpan2->setTraceId(new TraceId('21160e9b836d479f81611368b2aa3d2c')); + $genAiSpan2->setParentSpanId(new SpanId('a01b9f6349558cd1')); + $genAiSpan2->setOp('gen_ai.embeddings'); + $genAiSpan2->setDescription('embeddings create'); + $genAiSpan2->setStatus(SpanStatus::internalError()); + $genAiSpan2->setStartTimestamp(1597790837.5); + $genAiSpan2->setData([ + 'gen_ai.request.model' => 'text-embedding-3-small', + 'gen_ai.usage.input_tokens' => 7, + ]); + $genAiSpan2->finish(1597790838); + + $event = Event::createTransaction(new EventId('fc9442f5aef34234bb22b9a615e30ccd')); + $event->setSpans([$regularSpan, $genAiSpan1, $genAiSpan2]); + $event->setTransaction('POST /ai/chat'); + $event->setContext('trace', [ + 'trace_id' => '21160e9b836d479f81611368b2aa3d2c', + 'span_id' => '5dd538dc297544cc', + ]); + + yield [ + $event, + <<setSdkMetadata('dynamic_sampling_context', DynamicSamplingContext::fromHeader('sentry-public_key=public,sentry-trace_id=d49d9bf66f13450b81f65bc51cf49c03,sentry-sample_rate=1')); $event->setSdkMetadata('transaction_metadata', new TransactionMetadata());