diff --git a/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/docker/AppSecContainer.groovy b/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/docker/AppSecContainer.groovy index 7e985539c56..894ee73e169 100644 --- a/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/docker/AppSecContainer.groovy +++ b/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/docker/AppSecContainer.groovy @@ -1,6 +1,7 @@ package com.datadog.appsec.php.docker import com.datadog.appsec.php.mock_agent.MockDatadogAgent +import com.datadog.appsec.php.mock_openai.MockOpenAIServer import com.datadog.appsec.php.mock_agent.rem_cfg.RemoteConfigRequest import com.datadog.appsec.php.mock_agent.rem_cfg.RemoteConfigResponse import com.datadog.appsec.php.mock_agent.rem_cfg.Target @@ -55,7 +56,7 @@ class AppSecContainer> extends GenericContain .connectTimeout(Duration.ofSeconds(5)) .build() - private MockDatadogAgent mockDatadogAgent = new MockDatadogAgent() + private MockDatadogAgent mockDatadogAgent = new MockDatadogAgent() AppSecContainer(Map options) { super(imageNameFuture(options)) diff --git a/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/mock_openai/MockOpenAIServer.groovy b/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/mock_openai/MockOpenAIServer.groovy new file mode 100644 index 00000000000..a88e4ba1f35 --- /dev/null +++ b/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/mock_openai/MockOpenAIServer.groovy @@ -0,0 +1,153 @@ +package com.datadog.appsec.php.mock_openai + +import groovy.json.JsonSlurper +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import io.javalin.Javalin +import io.javalin.http.Context +import org.testcontainers.lifecycle.Startable + +@Slf4j +@CompileStatic +class MockOpenAIServer implements Startable { + Javalin httpServer + + @Override + void start() { + this.httpServer = Javalin.create(config -> { + config.showJavalinBanner = false + }) + + // Support both /path and /v1/path for OpenAI client compatibility + this.httpServer.post('/chat/completions', this.&handleChatCompletions) + this.httpServer.post('/v1/chat/completions', this.&handleChatCompletions) + this.httpServer.post('/completions', this.&handleCompletions) + this.httpServer.post('/v1/completions', this.&handleCompletions) + this.httpServer.post('/responses', this.&handleResponses) + this.httpServer.post('/v1/responses', this.&handleResponses) + + this.httpServer.error(404, ctx -> { + log.info("Unmatched OpenAI mock request: ${ctx.method()} ${ctx.path()}") + ctx.status(404).json(['error': 'Not Found']) + }) + this.httpServer.error(405, ctx -> { + ctx.status(405).json(['error': 'Method Not Allowed']) + }) + + this.httpServer.start(0) + } + + int getPort() { + this.httpServer.port() + } + + @Override + void stop() { + if (httpServer != null) { + this.httpServer.stop() + this.httpServer = null + } + } + + private static Map parseBody(Context ctx) { + String raw = ctx.body() + if (raw == null || raw.isEmpty()) { + return [:] + } + try { + def decoded = new JsonSlurper().parseText(raw) + return decoded instanceof Map ? (Map) decoded : [:] + } catch (Exception e) { + return [:] + } + } + + private static Map fakeUsage() { + [ + 'prompt_tokens' : 1, + 'completion_tokens': 2, + 'total_tokens' : 3, + ] + } + + private void handleChatCompletions(Context ctx) { + Map body = parseBody(ctx) + String model = (body['model'] as String) ?: 'gpt-4.1' + ctx.json([ + 'id' : 'chatcmpl-fake-internal', + 'object' : 'chat.completion', + 'created': (long)(System.currentTimeMillis() / 1000), + 'model' : model, + 'choices': [ + [ + 'index' : 0, + 'message' : [ + 'role' : 'assistant', + 'content': 'Fake response from internal_server mock.', + ], + 'finish_reason': 'stop', + ], + ], + 'usage' : fakeUsage(), + ]) + } + + private void handleCompletions(Context ctx) { + Map body = parseBody(ctx) + String model = (body['model'] as String) ?: 'text-davinci-003' + ctx.json([ + 'id' : 'cmpl-fake-internal', + 'object' : 'text_completion', + 'created': (long)(System.currentTimeMillis() / 1000), + 'model' : model, + 'choices': [ + [ + 'text' : 'Fake completion from internal_server mock.', + 'index' : 0, + 'finish_reason': 'stop', + 'logprobs' : null, + ], + ], + 'usage' : fakeUsage(), + ]) + } + + private void handleResponses(Context ctx) { + Map body = parseBody(ctx) + String model = (body['model'] as String) ?: 'gpt-4.1' + ctx.json([ + 'id' : 'resp-fake-internal', + 'object' : 'response', + 'created_at' : (long)(System.currentTimeMillis() / 1000), + 'status' : 'completed', + 'model' : model, + 'output' : [ + [ + 'type' : 'message', + 'id' : 'msg-fake-internal', + 'role' : 'assistant', + 'status' : 'completed', + 'content': [ + [ + 'type' : 'output_text', + 'text' : 'Fake response from internal_server mock.', + 'annotations': [], + ], + ], + ], + ], + 'output_text' : 'Fake response from internal_server mock.', + 'parallel_tool_calls' : false, + 'tool_choice' : 'none', + 'tools' : [], + 'store' : true, + 'usage' : [ + 'input_tokens' : 1, + 'input_tokens_details' : ['cached_tokens': 0], + 'output_tokens' : 2, + 'output_tokens_details' : ['reasoning_tokens': 0], + 'total_tokens' : 3, + ], + ]) + } +} diff --git a/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/LlmEventsTests.groovy b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/LlmEventsTests.groovy new file mode 100644 index 00000000000..4833bcf5571 --- /dev/null +++ b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/LlmEventsTests.groovy @@ -0,0 +1,107 @@ +package com.datadog.appsec.php.integration + +import com.datadog.appsec.php.docker.AppSecContainer +import com.datadog.appsec.php.docker.FailOnUnmatchedTraces +import com.datadog.appsec.php.mock_openai.MockOpenAIServer +import com.datadog.appsec.php.docker.InspectContainerHelper +import com.datadog.appsec.php.model.Span +import com.datadog.appsec.php.model.Trace +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestMethodOrder +import org.junit.jupiter.api.condition.EnabledIf +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers + +import java.io.InputStream + +import static org.testcontainers.containers.Container.ExecResult +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +import static com.datadog.appsec.php.integration.TestParams.getPhpVersion +import static com.datadog.appsec.php.integration.TestParams.getVariant +import static com.datadog.appsec.php.integration.TestParams.phpVersionAtLeast +import com.datadog.appsec.php.TelemetryHelpers +import static java.net.http.HttpResponse.BodyHandlers.ofString + +@Testcontainers +@EnabledIf('isExpectedVersion') +class LlmEventsTests { + static final String MODEL = 'gpt-4.1' + static boolean expectedVersion = phpVersionAtLeast('8.2') && !variant.contains('zts') + + AppSecContainer getContainer() { + getClass().CONTAINER + } + + public static final MockOpenAIServer mockOpenAIServer = new MockOpenAIServer() + + @Container + @FailOnUnmatchedTraces + public static final AppSecContainer CONTAINER = + new AppSecContainer( + workVolume: this.name, + baseTag: 'apache2-mod-php', + phpVersion: phpVersion, + phpVariant: variant, + www: 'llm', + ) { + { + dependsOn mockOpenAIServer + } + + @Override + void configure() { + super.configure() + org.testcontainers.Testcontainers.exposeHostPorts(mockOpenAIServer.port) + withEnv('OPENAI_BASE_URL', "http://host.testcontainers.internal:${mockOpenAIServer.port}/v1") + } + } + + static void main(String[] args) { + InspectContainerHelper.run(CONTAINER) + } + + /** Common assertions for LLM endpoint spans. */ + static void assertLlmSpan(Trace trace, String model) { + Span span = trace.first() + assert span.meta.'appsec.events.llm.call.provider' == 'openai' + assert span.meta.'appsec.events.llm.call.model' == model + assert span.metrics._sampling_priority_v1 == 2.0d + } + + @Test + void 'OpenAI latest responses create'() { + def trace = container.traceFromRequest("/llm.php?model=${MODEL}&operation=openai-latest-responses.create") { HttpResponse resp -> + assert resp.statusCode() == 200 + } + assertLlmSpan(trace, MODEL) + } + + @Test + void 'OpenAI latest chat completions create'() { + def trace = container.traceFromRequest("/llm.php?model=${MODEL}&operation=openai-latest-chat.completions.create") { HttpResponse resp -> + assert resp.statusCode() == 200 + } + assertLlmSpan(trace, MODEL) + } + + @Test + void 'OpenAI latest completions create'() { + def trace = container.traceFromRequest("/llm.php?model=${MODEL}&operation=openai-latest-completions.create") { HttpResponse resp -> + assert resp.statusCode() == 200 + } + assertLlmSpan(trace, MODEL) + } + + @Test + void 'Root has no LLM tags'() { + def trace = container.traceFromRequest('/hello.php') { HttpResponse resp -> + assert resp.statusCode() == 200 + } + Span span = trace.first() + assert !span.meta.containsKey('appsec.events.llm.call.provider') + assert !span.meta.containsKey('appsec.events.llm.call.model') + } +} diff --git a/appsec/tests/integration/src/test/waf/recommended.json b/appsec/tests/integration/src/test/waf/recommended.json index 19d24c1d196..2766a4a9e34 100644 --- a/appsec/tests/integration/src/test/waf/recommended.json +++ b/appsec/tests/integration/src/test/waf/recommended.json @@ -6933,6 +6933,50 @@ "on_match": [ "stack_trace" ] + }, + { + "id": "llm-001-000", + "name": "LLM call", + "tags": { + "type": "llm.event", + "category": "business_logic", + "module": "business_logic" + }, + "min_version": "1.25.0", + "conditions": [ + { + "parameters": { + "inputs": [ + { + "address": "server.business_logic.llm.event", + "key_path": [ + "provider" + ] + } + ] + }, + "operator": "exists" + } + ], + "transformers": [], + "output": { + "event": false, + "keep": true, + "attributes": { + "appsec.events.llm.call.provider": { + "address": "server.business_logic.llm.event", + "key_path": [ + "provider" + ] + }, + "appsec.events.llm.call.model": { + "address": "server.business_logic.llm.event", + "key_path": [ + "model" + ] + } + } + } } ], "rules_compat": [ diff --git a/appsec/tests/integration/src/test/www/llm/composer.json b/appsec/tests/integration/src/test/www/llm/composer.json new file mode 100644 index 00000000000..7e8e5a2e134 --- /dev/null +++ b/appsec/tests/integration/src/test/www/llm/composer.json @@ -0,0 +1,8 @@ +{ + "name": "datadog/appsec-integration-tests", + "type": "project", + "require": { + "openai-php/client": "*", + "guzzlehttp/guzzle": "*" + } +} \ No newline at end of file diff --git a/appsec/tests/integration/src/test/www/llm/initialize.sh b/appsec/tests/integration/src/test/www/llm/initialize.sh new file mode 100755 index 00000000000..6d9ebb400b9 --- /dev/null +++ b/appsec/tests/integration/src/test/www/llm/initialize.sh @@ -0,0 +1,6 @@ +#!/bin/bash -e + +cd /var/www + +composer install --no-dev +chown -R www-data.www-data vendor diff --git a/appsec/tests/integration/src/test/www/llm/public/hello.php b/appsec/tests/integration/src/test/www/llm/public/hello.php new file mode 100644 index 00000000000..5d4a4097739 --- /dev/null +++ b/appsec/tests/integration/src/test/www/llm/public/hello.php @@ -0,0 +1,8 @@ + 'Missing or empty query parameters: model, operation']); + exit; +} + +try { + $baseUri = getenv('OPENAI_BASE_URL') ?: 'http://localhost/mockOpenAi/'; + $client = \OpenAI::factory() + ->withApiKey('sk-fake') + ->withBaseUri($baseUri) + ->make(); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode(['error' => 'OpenAI client init failed: ' . $e->getMessage()]); + exit; +} + +$params = ['model' => $model]; + +try { + switch ($operation) { + case 'openai-latest-responses.create': + $params['input'] = 'Hello'; + $response = $client->responses()->create($params); + break; + case 'openai-latest-chat.completions.create': + $params['messages'] = [['role' => 'user', 'content' => 'Hello']]; + $response = $client->chat()->create($params); + break; + case 'openai-latest-completions.create': + $params['prompt'] = 'Hello'; + $response = $client->completions()->create($params); + break; + default: + http_response_code(400); + echo json_encode(['error' => 'Unknown operation: ' . $operation]); + exit; + } + + // Return a simple success payload; the client returns response objects + echo json_encode([ + 'model' => $model, + 'operation' => $operation, + 'status' => 'ok', + ]); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode([ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'operation' => $operation, + ]); +} \ No newline at end of file diff --git a/src/DDTrace/Integrations/OpenAI/OpenAIIntegration.php b/src/DDTrace/Integrations/OpenAI/OpenAIIntegration.php index d3ad0024e67..cbe6cabc0f2 100644 --- a/src/DDTrace/Integrations/OpenAI/OpenAIIntegration.php +++ b/src/DDTrace/Integrations/OpenAI/OpenAIIntegration.php @@ -59,6 +59,16 @@ public static function setServiceName(SpanData $span) } } + public static function pushLlmEventForAppsec($model = null) + { + if (function_exists('datadog\appsec\push_addresses')) { + \datadog\appsec\push_addresses(["server.business_logic.llm.event" => [ + 'provider' => 'openai', + 'model' => $model, + ]]); + } + } + /** * Add instrumentation to OpenAI API Requests */ @@ -66,35 +76,39 @@ public static function init(): int { $logger = \dd_trace_env_config('DD_OPENAI_LOGS_ENABLED') ? new DatadogLogger() : null; + // [class, method, operationID, httpMethod, endpoint, reportApm, reportAppsec] $targets = [ - ['OpenAI\Resources\Completions', 'create', 'createCompletion', 'POST', '/v1/completions'], - ['OpenAI\Resources\Chat', 'create', 'createChatCompletion', 'POST', '/v1/chat/completions'], - ['OpenAI\Resources\Embeddings', 'create', 'createEmbedding', 'POST', '/v1/embeddings'], - ['OpenAI\Resources\Models', 'list', 'listModels', 'GET', '/v1/models'], - ['OpenAI\Resources\Files', 'list', 'listFiles', 'GET', '/v1/files'], - ['OpenAI\Resources\FineTuning', 'listJobs', 'listFineTunes', 'GET', '/v1/fine-tunes'], - ['OpenAI\Resources\Models', 'retrieve', 'retrieveModel', 'GET', '/v1/models/*'], - ['OpenAI\Resources\Files', 'retrieve', 'retrieveFile', 'GET', '/v1/files/*'], - ['OpenAI\Resources\FineTuning', 'retrieveJob', 'retrieveFineTune', 'GET', '/v1/fine-tunes/*'], - ['OpenAI\Resources\Models', 'delete', 'deleteModel', 'DELETE', '/v1/models/*'], - ['OpenAI\Resources\Files', 'delete', 'deleteFile', 'DELETE', '/v1/files/*'], - ['OpenAI\Resources\Images', 'create', 'createImage', 'POST', '/v1/images/generations'], - ['OpenAI\Resources\Images', 'edit', 'createImageEdit', 'POST', '/v1/images/edits'], - ['OpenAI\Resources\Images', 'variation', 'createImageVariation', 'POST', '/v1/images/variations'], - ['OpenAI\Resources\Audio', 'transcribe', 'createTranscription', 'POST', '/v1/audio/transcriptions'], - ['OpenAI\Resources\Audio', 'translate', 'createTranslation', 'POST', '/v1/audio/translations'], - ['OpenAI\Resources\Moderations', 'create', 'createModeration', 'POST', '/v1/moderations'], - ['OpenAI\Resources\Files', 'upload', 'createFile', 'POST', '/v1/files'], - ['OpenAI\Resources\Files', 'download', 'downloadFile', 'GET', '/v1/files/*/content'], - ['OpenAI\Resources\FineTuning', 'createJob', 'createFineTune', 'POST', '/v1/fine-tunes'], - ['OpenAI\Resources\FineTunes', 'cancel', 'cancelFineTune', 'POST', '/v1/fine-tunes/*/cancel'], - ['OpenAI\Resources\FineTunes', 'listEvents', 'listFineTuneEvents', 'GET', '/v1/fine-tunes/*/events'], + ['OpenAI\Resources\Completions', 'create', 'createCompletion', 'POST', '/v1/completions', true, true], + ['OpenAI\Resources\Chat', 'create', 'createChatCompletion', 'POST', '/v1/chat/completions', true, true], + ['OpenAI\Resources\Embeddings', 'create', 'createEmbedding', 'POST', '/v1/embeddings', true, false], + ['OpenAI\Resources\Models', 'list', 'listModels', 'GET', '/v1/models', true, false], + ['OpenAI\Resources\Files', 'list', 'listFiles', 'GET', '/v1/files', true, false], + ['OpenAI\Resources\FineTuning', 'listJobs', 'listFineTunes', 'GET', '/v1/fine-tunes', true, false], + ['OpenAI\Resources\Models', 'retrieve', 'retrieveModel', 'GET', '/v1/models/*', true, false], + ['OpenAI\Resources\Files', 'retrieve', 'retrieveFile', 'GET', '/v1/files/*', true, false], + ['OpenAI\Resources\FineTuning', 'retrieveJob', 'retrieveFineTune', 'GET', '/v1/fine-tunes/*', true, false], + ['OpenAI\Resources\Models', 'delete', 'deleteModel', 'DELETE', '/v1/models/*', true, false], + ['OpenAI\Resources\Files', 'delete', 'deleteFile', 'DELETE', '/v1/files/*', true, false], + ['OpenAI\Resources\Images', 'create', 'createImage', 'POST', '/v1/images/generations', true, false], + ['OpenAI\Resources\Images', 'edit', 'createImageEdit', 'POST', '/v1/images/edits', true, false], + ['OpenAI\Resources\Images', 'variation', 'createImageVariation', 'POST', '/v1/images/variations', true, false], + ['OpenAI\Resources\Audio', 'transcribe', 'createTranscription', 'POST', '/v1/audio/transcriptions', true, false], + ['OpenAI\Resources\Audio', 'translate', 'createTranslation', 'POST', '/v1/audio/translations', true, false], + ['OpenAI\Resources\Moderations', 'create', 'createModeration', 'POST', '/v1/moderations', true, false], + ['OpenAI\Resources\Files', 'upload', 'createFile', 'POST', '/v1/files', true, false], + ['OpenAI\Resources\Files', 'download', 'downloadFile', 'GET', '/v1/files/*/content', true, false], + ['OpenAI\Resources\FineTuning', 'createJob', 'createFineTune', 'POST', '/v1/fine-tunes', true, false], + ['OpenAI\Resources\FineTunes', 'cancel', 'cancelFineTune', 'POST', '/v1/fine-tunes/*/cancel', true, false], + ['OpenAI\Resources\FineTunes', 'listEvents', 'listFineTuneEvents', 'GET', '/v1/fine-tunes/*/events', true, false], + ['OpenAI\Resources\Responses', 'create', 'createResponse', 'POST', '/v1/responses', false, true], ]; + // [class, method, operationID, httpMethod, endpoint, reportApm, reportAppsec] $streamedTargets = [ - ['OpenAI\Resources\Completions', 'createStreamed', 'createCompletion', 'POST', '/v1/completions'], - ['OpenAI\Resources\Chat', 'createStreamed', 'createChatCompletion', 'POST', '/v1/chat/completions'], - ['OpenAI\Resources\FineTunes', 'listEventsStreamed', 'listFineTuneEvents', 'GET', '/v1/fine-tunes/*/events'], + ['OpenAI\Resources\Completions', 'createStreamed', 'createCompletion', 'POST', '/v1/completions', true, true], + ['OpenAI\Resources\Chat', 'createStreamed', 'createChatCompletion', 'POST', '/v1/chat/completions', true, true], + ['OpenAI\Resources\FineTunes', 'listEventsStreamed', 'listFineTuneEvents', 'GET', '/v1/fine-tunes/*/events', true, false], + ['OpenAI\Resources\Responses', 'createStreamed', 'createResponse', 'POST', '/v1/responses', false, true], ]; \DDTrace\hook_method( @@ -133,66 +147,97 @@ static function ($This, $scope, $args) { } ); - $handleRequestPrehook = fn ($streamed, $operationID) => function (\DDTrace\SpanData $span, $args) use ($operationID, $streamed) { - OpenAIIntegration::setServiceName($span); - $clientData = ObjectKVStore::get($this, 'client_data'); - if (\is_null($clientData)) { - $transporter = ObjectKVStore::get($this, 'transporter'); - $clientData = ObjectKVStore::get($transporter, 'client_data'); - ObjectKVStore::put($this, 'client_data', $clientData); - } - /** @var array{baseUri: string, headers: string, apiKey: ?string} $clientData */ - OpenAIIntegration::handleRequest( - span: $span, - operationID: $operationID, - args: $args, - basePath: $clientData['baseUri'], - apiKey: $clientData['apiKey'], - streamed: $streamed - ); + $appsecOnlyPrehook = static function (bool $reportAppsec) { + return static function ($This, $scope, $args) use ($reportAppsec): void { + if ($reportAppsec) { + OpenAIIntegration::pushLlmEventForAppsec($args[0]['model'] ?? null); + } + }; }; - foreach ($targets as [$class, $method, $operationID, $httpMethod, $endpoint]) { - \DDTrace\trace_method( - $class, - $method, - [ - 'prehook' => $handleRequestPrehook(false, $operationID), - 'posthook' => static function (\DDTrace\SpanData $span, $args, $response) use ($logger, $httpMethod, $endpoint) { - /** @var (\OpenAI\Contracts\ResponseContract&\OpenAI\Contracts\ResponseHasMetaInformationContract)|string $response */ - // Files::download - i.e., downloadFile - returns a string instead of a Response instance - self::handleResponse( - span: $span, - logger: $logger, - headers: $response ? (method_exists($response, 'meta') ? $response->meta()->toArray() : []) : [], - response: \is_string($response) ? $response : ($response ? $response->toArray() : []), - httpMethod: $httpMethod, - endpoint: $endpoint, - ); - } - ] - ); + $handleRequestPrehook = static function ($streamed, $operationID, $reportApm, $reportAppsec) { + return function (\DDTrace\SpanData $span, $args) use ($operationID, $streamed, $reportApm, $reportAppsec) { + if ($reportAppsec) { + OpenAIIntegration::pushLlmEventForAppsec($args[0]['model'] ?? null); + } + + // This should not happen but just in case + if (!$reportApm) { + return; + } + OpenAIIntegration::setServiceName($span); + $clientData = ObjectKVStore::get($this, 'client_data'); + if (\is_null($clientData)) { + $transporter = ObjectKVStore::get($this, 'transporter'); + $clientData = ObjectKVStore::get($transporter, 'client_data'); + ObjectKVStore::put($this, 'client_data', $clientData); + } + /** @var array{baseUri: string, headers: string, apiKey: ?string} $clientData */ + OpenAIIntegration::handleRequest( + span: $span, + operationID: $operationID, + args: $args, + basePath: $clientData['baseUri'], + apiKey: $clientData['apiKey'], + streamed: $streamed + ); + }; + }; + + foreach ($targets as [$class, $method, $operationID, $httpMethod, $endpoint, $reportApm, $reportAppsec]) { + if ($reportApm) { + \DDTrace\trace_method( + $class, + $method, + [ + 'prehook' => $handleRequestPrehook(false, $operationID, $reportApm, $reportAppsec), + 'posthook' => static function (\DDTrace\SpanData $span, $args, $response) use ($logger, $httpMethod, $endpoint, $reportApm) { + /** @var (\OpenAI\Contracts\ResponseContract&\OpenAI\Contracts\ResponseHasMetaInformationContract)|string $response */ + // Files::download - i.e., downloadFile - returns a string instead of a Response instance + self::handleResponse( + span: $span, + logger: $logger, + headers: $response ? (method_exists($response, 'meta') ? $response->meta()->toArray() : []) : [], + response: \is_string($response) ? $response : ($response ? $response->toArray() : []), + httpMethod: $httpMethod, + endpoint: $endpoint, + reportApm: $reportApm, + ); + } + ] + ); + } elseif ($reportAppsec) { + // Only appsec reporting, use hook_method to avoid creating a span + \DDTrace\hook_method($class, $method, $appsecOnlyPrehook($reportAppsec)); + } } - foreach ($streamedTargets as [$class, $method, $operationID, $httpMethod, $endpoint]) { - \DDTrace\trace_method( - $class, - $method, - [ - 'prehook' => $handleRequestPrehook(true, $operationID), - 'posthook' => static function (\DDTrace\SpanData $span, $args, $response) use ($logger, $httpMethod, $endpoint) { - /** @var \OpenAI\Responses\StreamResponse $response */ - self::handleStreamedResponse( - span: $span, - logger: $logger, - headers: $response->meta()->toArray(), - response: $response, - httpMethod: $httpMethod, - endpoint: $endpoint, - ); - } - ] - ); + foreach ($streamedTargets as [$class, $method, $operationID, $httpMethod, $endpoint, $reportApm, $reportAppsec]) { + if ($reportApm) { + // Use trace_method which handles both APM and appsec in the prehook + \DDTrace\trace_method( + $class, + $method, + [ + 'prehook' => $handleRequestPrehook(true, $operationID, $reportApm, $reportAppsec), + 'posthook' => static function (\DDTrace\SpanData $span, $args, $response) use ($logger, $httpMethod, $endpoint, $reportApm) { + /** @var \OpenAI\Responses\StreamResponse $response */ + self::handleStreamedResponse( + span: $span, + logger: $logger, + headers: $response->meta()->toArray(), + response: $response, + httpMethod: $httpMethod, + endpoint: $endpoint, + reportApm: $reportApm, + ); + } + ] + ); + } elseif ($reportAppsec) { + // Only appsec reporting, use hook_method to avoid creating a span + \DDTrace\hook_method($class, $method, $appsecOnlyPrehook($reportAppsec)); + } } \DDTrace\install_hook( @@ -419,8 +464,14 @@ public static function handleResponse( array|string $response, string $httpMethod, string $endpoint, + bool $reportApm = true, ) { + // This should not happen but just in case + if (!$reportApm) { + return; + } + $operationID = \explode('/', $span->resource)[0]; if ($operationID === 'downloadFile') { @@ -1011,8 +1062,13 @@ public static function handleStreamedResponse( StreamResponse $response, string $httpMethod, string $endpoint, + bool $reportApm = true, ) { + if (!$reportApm) { + return; + } + $responseArray = self::readAndStoreStreamedResponse($span, $response); $operationID = \explode('/', $span->resource)[0]; diff --git a/tests/Appsec/Mock.php b/tests/Appsec/Mock.php index 1ae8da21a6c..386846362e5 100644 --- a/tests/Appsec/Mock.php +++ b/tests/Appsec/Mock.php @@ -2,25 +2,130 @@ namespace datadog\appsec; -if (!class_exists('datadog\appsec\AppsecStatus')) { - class AppsecStatus + +if (!class_exists('datadog\appsec\AppsecStatusBase')) { + /** + * Shared logic for filtering events by names and addresses. + * + * @internal + */ + abstract class AppsecStatusBase { - private static $instance = null; - private $connection; + /** + * @param array $rows + * @return array> + */ + protected static function filterEventsByNamesAndAddresses(array $rows, string $token, array $names, array $addresses): array + { + $result = []; + foreach ($rows as $row) { + if ($row['token'] !== $token) { + continue; + } + $new = json_decode($row['event'], true); + if ($new === null) { + continue; + } + if (empty($names) || in_array($new['eventName'], $names) && + (empty($addresses) || !empty(array_intersect($addresses, array_keys($new[0] ?? []))))) { + $result[] = $new; + } + } + return $result; + } + + /** + * @return void + */ + abstract public function init(); + + /** + * @return void + */ + abstract public function setDefaults(); - protected function __construct() + /** + * @param array $event + * @return void + */ + abstract public function addEvent(array $event, $eventName); + + /** + * @return array> + */ + abstract public function getEvents(array $names = [], array $addresses = []): array; + + /** + * @param array $event + * @return void + */ + abstract public function simulateBlockOnEvent($event); + } +} +if (!class_exists('datadog\appsec\AppsecStatusInMemory')) { + final class AppsecStatusInMemory extends AppsecStatusBase + { + /** @var array */ + private $events = []; + /** @var array */ + private $blockedEvents = []; + + public function init() { + $this->events = []; + $this->blockedEvents = []; } - public static function getInstance() + public function setDefaults() { - if (!self::$instance) { - self::$instance = new static(); + $token = ini_get("datadog.trace.agent_test_session_token"); + $this->events = array_values(array_filter($this->events, function ($row) use ($token) { + return $row['token'] !== $token; + })); + $this->blockedEvents = array_values(array_filter($this->blockedEvents, function ($row) use ($token) { + return $row['token'] !== $token; + })); + } + + public function addEvent(array $event, $eventName) + { + $event['eventName'] = $eventName; + $jsonEvent = json_encode($event); + $token = ini_get("datadog.trace.agent_test_session_token"); + + $eventIsBlocked = false; + foreach ($this->blockedEvents as $row) { + if ($row['event'] === $jsonEvent && $row['token'] === $token) { + $eventIsBlocked = true; + break; + } } + $this->events[] = ['event' => $jsonEvent, 'token' => $token]; + if ($eventIsBlocked) { + \DDTrace\Testing\trigger_error("Datadog blocked the request and NON RELEVANT TEXT FROM HERE", E_ERROR); + } + } - return self::$instance; + public function getEvents(array $names = [], array $addresses = []): array + { + $token = ini_get("datadog.trace.agent_test_session_token"); + return self::filterEventsByNamesAndAddresses($this->events, $token, $names, $addresses); } + public function simulateBlockOnEvent($event) + { + $jsonEvent = json_encode($event); + $token = ini_get("datadog.trace.agent_test_session_token"); + $this->blockedEvents[] = ['event' => $jsonEvent, 'token' => $token]; + } + } +} + +if (!class_exists('datadog\appsec\AppsecStatusMysql')) { + final class AppsecStatusMysql extends AppsecStatusBase + { + private $connection = null; + protected function getDbPdo() { if (!isset($this->connection)) { @@ -32,10 +137,7 @@ protected function getDbPdo() return $this->connection; } - /** - * Not all test are interested on events but frameworks are instrumented so this check is to avoid errors - */ - private function initiated() + private function initiated(): bool { $stmt = $this->getDbPdo()->prepare("SELECT * FROM information_schema.tables WHERE table_name = :table_name"); $stmt->execute(['table_name' => 'appsec_events']); @@ -53,10 +155,11 @@ public function setDefaults() if (!$this->initiated()) { return; } + $token = ini_get("datadog.trace.agent_test_session_token"); $stmt = $this->getDbPdo()->prepare("DELETE FROM appsec_events WHERE token = :token"); - $stmt->execute(['token' => ini_get("datadog.trace.agent_test_session_token")]); + $stmt->execute(['token' => $token]); $stmt = $this->getDbPdo()->prepare("DELETE FROM appsec_blocked_events WHERE token = :token"); - $stmt->execute(['token' => ini_get("datadog.trace.agent_test_session_token")]); + $stmt->execute(['token' => $token]); } public function addEvent(array $event, $eventName) @@ -69,57 +172,66 @@ public function addEvent(array $event, $eventName) $token = ini_get("datadog.trace.agent_test_session_token"); $stmt = $this->getDbPdo()->prepare("SELECT * from appsec_blocked_events where event=:event and token=:token"); - $stmt->execute([ - 'event' => $jsonEvent, - 'token' => $token - ]); + $stmt->execute(['event' => $jsonEvent, 'token' => $token]); $eventIsBlocked = $stmt->rowCount() > 0; - + $stmt = $this->getDbPdo()->prepare("INSERT INTO appsec_events VALUES (:event, :token)"); - $stmt->execute([ - 'event' => $jsonEvent, - 'token' => $token - ]); + $stmt->execute(['event' => $jsonEvent, 'token' => $token]); if ($eventIsBlocked) { \DDTrace\Testing\trigger_error("Datadog blocked the request and NON RELEVANT TEXT FROM HERE", E_ERROR); } } - public function getEvents(array $names = [], array $addresses = []) + public function getEvents(array $names = [], array $addresses = []): array { - $result = []; - if (!$this->initiated()) { - return $result; + return []; } - + $token = ini_get("datadog.trace.agent_test_session_token"); $stmt = $this->getDbPdo()->prepare("SELECT * FROM appsec_events WHERE token = :token"); - $stmt->execute(['token' => ini_get("datadog.trace.agent_test_session_token")]); - $events = $stmt->fetchAll(); - - foreach ($events as $event) { - $new = json_decode($event['event'], true); - if ($new === null) { - continue; - } - if (empty($names) || in_array($new['eventName'], $names) && - (empty($addresses) || !empty(array_intersect($addresses, array_keys($new[0]))))) { - $result[] = $new; - } - } - - return $result; + $stmt->execute(['token' => $token]); + $events = $stmt->fetchAll(\PDO::FETCH_ASSOC); + return self::filterEventsByNamesAndAddresses($events, $token, $names, $addresses); } - public function simulateBlockOnEvent($event) { + public function simulateBlockOnEvent($event) + { $jsonEvent = json_encode($event); $token = ini_get("datadog.trace.agent_test_session_token"); $stmt = $this->getDbPdo()->prepare("INSERT INTO appsec_blocked_events VALUES (:event, :token)"); - $stmt->execute([ - 'event' => $jsonEvent, - 'token' => $token - ]); + $stmt->execute(['event' => $jsonEvent, 'token' => $token]); + } + } +} + +if (!class_exists('datadog\appsec\AppsecStatus')) { + class AppsecStatus + { + /** @var AppsecStatusBase|null */ + private static $instance = null; + + /** + * The first call defines the mode: getInstance(true) = in-memory, getInstance() or getInstance(false) = MySQL. + * Mode is fixed until clearInstances() is called. + * + * @param bool $inMemory When true, use in-memory storage (only applied on first call) + * @return AppsecStatusBase + */ + public static function getInstance(bool $inMemory = false) + { + if (self::$instance === null) { + self::$instance = $inMemory ? new AppsecStatusInMemory() : new AppsecStatusMysql(); + } + return self::$instance; + } + + /** + * @return void + */ + public static function clearInstances() + { + self::$instance = null; } } } diff --git a/tests/Integrations/OpenAI/Latest/OpenAITest.php b/tests/Integrations/OpenAI/Latest/OpenAITest.php index b536e49451b..89293eb7075 100644 --- a/tests/Integrations/OpenAI/Latest/OpenAITest.php +++ b/tests/Integrations/OpenAI/Latest/OpenAITest.php @@ -5,14 +5,27 @@ use DDTrace\Tests\Common\IntegrationTestCase; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Stream; +use datadog\appsec\AppsecStatus; +use DDTrace\Tests\Common\SnapshotTestTrait; class OpenAITest extends IntegrationTestCase { + use SnapshotTestTrait +; private $errorLogSize = 0; public static function ddSetUpBeforeClass() { parent::ddSetUpBeforeClass(); + self::putEnv('APPSEC_MOCK_ENABLED=true'); + AppsecStatus::getInstance(true); + } + + public static function ddTearDownAfterClass() + { + parent::ddTearDownAfterClass(); + AppsecStatus::clearInstances(); + self::putEnv('APPSEC_MOCK_ENABLED'); } private function checkErrors() @@ -41,12 +54,16 @@ protected function ddSetUp() 'DD_SERVICE=openai-test', 'DD_ENV=test', 'DD_VERSION=1.0', + 'APPSEC_MOCK_ENABLED=true', ]); if (file_exists(__DIR__ . "/openai.log")) { $this->errorLogSize = (int)filesize(__DIR__ . "/openai.log"); } else { $this->errorLogSize = 0; } + AppsecStatus::getInstance()->setDefaults(); + $token = $this->generateToken(); + update_test_agent_session_token($token); } protected function envsToCleanUpAtTearDown() @@ -116,6 +133,10 @@ public function testCreateCompletion() ], 'user' => 'dd-trace' ]); + $events = AppsecStatus::getInstance()->getEvents(['push_addresses'], ['server.business_logic.llm.event']); + $this->assertEquals(1, count($events)); + $this->assertEquals('openai', $events[0][0]["server.business_logic.llm.event"]['provider']); + $this->assertEquals('da-vince', $events[0][0]["server.business_logic.llm.event"]['model']); } public function testCreateChatCompletion() @@ -124,6 +145,24 @@ public function testCreateChatCompletion() 'model' => 'gpt-3.5-turbo', 'messages' => ['role' => 'user', 'content' => 'Hello!'], ]); + + $events = AppsecStatus::getInstance()->getEvents(['push_addresses'], ['server.business_logic.llm.event']); + $this->assertEquals(1, count($events)); + $this->assertEquals('openai', $events[0][0]["server.business_logic.llm.event"]['provider']); + $this->assertEquals('gpt-3.5-turbo', $events[0][0]["server.business_logic.llm.event"]['model']); + } + + public function testCreateResponse() + { + $this->call('responses', 'create', metaHeaders(), response(), [ + 'model' => 'gpt-3.5-turbo', + 'messages' => ['role' => 'user', 'content' => 'Hello!'], + ]); + + $events = AppsecStatus::getInstance()->getEvents(['push_addresses'], ['server.business_logic.llm.event']); + $this->assertEquals(1, count($events)); + $this->assertEquals('openai', $events[0][0]["server.business_logic.llm.event"]['provider']); + $this->assertEquals('gpt-3.5-turbo', $events[0][0]["server.business_logic.llm.event"]['model']); } public function testCreateChatCompletionWithMultipleRoles() @@ -404,6 +443,11 @@ public function testCreateCompletionStream() 'model' => 'gpt-3.5-turbo-instruct', 'prompt' => 'hi', ]); + + $events = AppsecStatus::getInstance()->getEvents(['push_addresses'], ['server.business_logic.llm.event']); + $this->assertEquals(1, count($events)); + $this->assertEquals('openai', $events[0][0]["server.business_logic.llm.event"]['provider']); + $this->assertEquals('gpt-3.5-turbo-instruct', $events[0][0]["server.business_logic.llm.event"]['model']); } public function testCreateChatCompletionStream() @@ -412,6 +456,24 @@ public function testCreateChatCompletionStream() 'model' => 'gpt-3.5-turbo', 'messages' => ['role' => 'user', 'content' => 'Hello!'], ]); + + $events = AppsecStatus::getInstance()->getEvents(['push_addresses'], ['server.business_logic.llm.event']); + $this->assertEquals(1, count($events)); + $this->assertEquals('openai', $events[0][0]["server.business_logic.llm.event"]['provider']); + $this->assertEquals('gpt-3.5-turbo', $events[0][0]["server.business_logic.llm.event"]['model']); + } + + public function testCreateResponseStream() + { + $this->callStreamed('responses', 'createStreamed', metaHeaders(), responseStream(), [ + 'model' => 'gpt-3.5-turbo', + 'messages' => ['role' => 'user', 'content' => 'Hello!'], + ]); + + $events = AppsecStatus::getInstance()->getEvents(['push_addresses'], ['server.business_logic.llm.event']); + $this->assertEquals(1, count($events)); + $this->assertEquals('openai', $events[0][0]["server.business_logic.llm.event"]['provider']); + $this->assertEquals('gpt-3.5-turbo', $events[0][0]["server.business_logic.llm.event"]['model']); } public function testListFineTuneEventsStream() diff --git a/tests/Integrations/OpenAI/Latest/composer.json b/tests/Integrations/OpenAI/Latest/composer.json index 0445bb70c63..304d72f18e0 100644 --- a/tests/Integrations/OpenAI/Latest/composer.json +++ b/tests/Integrations/OpenAI/Latest/composer.json @@ -3,5 +3,8 @@ "openai-php/client": "0.16.1", "guzzlehttp/guzzle": "^7.8.1", "guzzlehttp/psr7": "^2.6.2" + }, + "autoload-dev": { + "files": ["../../../../tests/Appsec/Mock.php"] } } \ No newline at end of file diff --git a/tests/OpenAI/Fixtures/Response.php b/tests/OpenAI/Fixtures/Response.php new file mode 100644 index 00000000000..3aa5e490023 --- /dev/null +++ b/tests/OpenAI/Fixtures/Response.php @@ -0,0 +1,69 @@ + 'resp-fake-internal', + 'object' => 'response', + 'created_at' => 1677652288, + 'status' => 'completed', + 'model' => 'gpt-3.5-turbo-0125', + // Fields commonly expected by the OpenAI PHP client for Responses API + 'error' => null, + 'incomplete_details' => null, + 'instructions' => 'You are a helpful assistant.', + 'max_output_tokens' => null, + 'output' => [ + [ + 'type' => 'message', + 'id' => 'msg-fake-internal', + 'role' => 'assistant', + 'status' => 'completed', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'Fake response from internal_server mock.', + 'annotations' => [], + ], + ], + ], + ], + 'output_text' => 'Fake response from internal_server mock.', + 'previous_response_id' => null, + 'parallel_tool_calls' => false, + 'tool_choice' => 'none', + 'tools' => [], + 'store' => true, + 'reasoning' => [ + 'effort' => null, + 'summary' => null, + ], + 'temperature' => 1.0, + 'text' => [ + 'format' => [ + 'type' => 'text', + ], + ], + 'top_p' => 1.0, + 'truncation' => 'disabled', + 'user' => null, + 'metadata' => [], + 'usage' => [ + 'input_tokens' => 1, + 'input_tokens_details' => ['cached_tokens' => 0], + 'output_tokens' => 2, + 'output_tokens_details' => ['reasoning_tokens' => 0], + 'total_tokens' => 3, + ], + ]; +} + +/** + * @return resource + */ +function responseStream() +{ + return fopen(__DIR__.'/Streams/ResponseCreate.txt', 'r'); +} \ No newline at end of file diff --git a/tests/OpenAI/Fixtures/Streams/ResponseCreate.txt b/tests/OpenAI/Fixtures/Streams/ResponseCreate.txt new file mode 100644 index 00000000000..478ffae2e84 --- /dev/null +++ b/tests/OpenAI/Fixtures/Streams/ResponseCreate.txt @@ -0,0 +1,10 @@ +data: {"type":"response.created","response":{"id":"resp_67c9fdcecf488190bdd9a0409de3a1ec07b8b0ad4e5eb654","object":"response","created_at":1741290958,"status":"in_progress","error":null,"incomplete_details":null,"instructions":"You are a helpful assistant.","max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} +data: {"type":"response.in_progress","response":{"id":"resp_67c9fdcecf488190bdd9a0409de3a1ec07b8b0ad4e5eb654","object":"response","created_at":1741290958,"status":"in_progress","error":null,"incomplete_details":null,"instructions":"You are a helpful assistant.","max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}}} +data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_67c9fdcf37fc8190ba82116e33fb28c507b8b0ad4e5eb654","type":"message","status":"in_progress","role":"assistant","content":[]}} +data: {"type":"response.content_part.added","item_id":"msg_67c9fdcf37fc8190ba82116e33fb28c507b8b0ad4e5eb654","output_index":0,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} +data: {"type":"response.output_text.delta","item_id":"msg_67c9fdcf37fc8190ba82116e33fb28c507b8b0ad4e5eb654","output_index":0,"content_index":0,"delta":"Hi","sequence_number":0} +data: {"type":"response.output_text.done","item_id":"msg_67c9fdcf37fc8190ba82116e33fb28c507b8b0ad4e5eb654","output_index":0,"content_index":0,"text":"Hi there! How can I assist you today?"} +data: {"type":"response.content_part.done","item_id":"msg_67c9fdcf37fc8190ba82116e33fb28c507b8b0ad4e5eb654","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hi there! How can I assist you today?","annotations":[]}} +data: {"type":"response.output_item.done","output_index":0,"item":{"id":"msg_67c9fdcf37fc8190ba82116e33fb28c507b8b0ad4e5eb654","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hi there! How can I assist you today?","annotations":[]}]}} +data: {"type":"response.completed","response":{"id":"resp_67c9fdcecf488190bdd9a0409de3a1ec07b8b0ad4e5eb654","object":"response","created_at":1741290958,"status":"completed","error":null,"incomplete_details":null,"instructions":"You are a helpful assistant.","max_output_tokens":null,"model":"gpt-4o-2024-08-06","output":[{"id":"msg_67c9fdcf37fc8190ba82116e33fb28c507b8b0ad4e5eb654","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hi there! How can I assist you today?","annotations":[]}]}],"parallel_tool_calls":true,"previous_response_id":null,"reasoning":{"effort":null,"summary":null},"store":true,"temperature":1.0,"text":{"format":{"type":"text"}},"tool_choice":"auto","tools":[],"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":37,"output_tokens":11,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":48},"user":null,"metadata":{}}} +data: [DONE] diff --git a/tests/snapshots/logs/tests.integrations.open_ai.open_ai_test.test_create_response.txt b/tests/snapshots/logs/tests.integrations.open_ai.open_ai_test.test_create_response.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/snapshots/logs/tests.integrations.open_ai.open_ai_test.test_create_response_stream.txt b/tests/snapshots/logs/tests.integrations.open_ai.open_ai_test.test_create_response_stream.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/snapshots/metrics/tests.integrations.open_ai.open_ai_test.test_create_response.txt b/tests/snapshots/metrics/tests.integrations.open_ai.open_ai_test.test_create_response.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/snapshots/tests.integrations.open_ai.open_ai_test.test_create_response.json b/tests/snapshots/tests.integrations.open_ai.open_ai_test.test_create_response.json new file mode 100644 index 00000000000..76291b82343 --- /dev/null +++ b/tests/snapshots/tests.integrations.open_ai.open_ai_test.test_create_response.json @@ -0,0 +1,27 @@ +[[ + { + "name": "Psr\\Http\\Client\\ClientInterface.sendRequest", + "service": "openai-test", + "resource": "sendRequest", + "trace_id": 0, + "span_id": 1, + "parent_id": 0, + "type": "http", + "meta": { + "_dd.p.dm": "0", + "_dd.p.tid": "69a1cb2d00000000", + "component": "psr18", + "env": "test", + "http.method": "POST", + "http.status_code": "200", + "http.url": "https://api.openai.com/v1/responses?foo=bar", + "network.destination.name": "api.openai.com", + "runtime-id": "52edddee-1942-4e53-8c67-ce2f4cafbb79", + "span.kind": "client", + "version": "1.0" + }, + "metrics": { + "_dd.agent_psr": 1, + "_sampling_priority_v1": 1 + } + }]] diff --git a/tests/snapshots/tests.integrations.open_ai.open_ai_test.test_create_response_stream.json b/tests/snapshots/tests.integrations.open_ai.open_ai_test.test_create_response_stream.json new file mode 100644 index 00000000000..9fe0f6e8c80 --- /dev/null +++ b/tests/snapshots/tests.integrations.open_ai.open_ai_test.test_create_response_stream.json @@ -0,0 +1,27 @@ +[[ + { + "name": "Psr\\Http\\Client\\ClientInterface.sendRequest", + "service": "openai-test", + "resource": "sendRequest", + "trace_id": 0, + "span_id": 1, + "parent_id": 0, + "type": "http", + "meta": { + "_dd.p.dm": "0", + "_dd.p.tid": "69a1cb3c00000000", + "component": "psr18", + "env": "test", + "http.method": "POST", + "http.status_code": "200", + "http.url": "https://api.openai.com/v1/responses?foo=bar", + "network.destination.name": "api.openai.com", + "runtime-id": "52edddee-1942-4e53-8c67-ce2f4cafbb79", + "span.kind": "client", + "version": "1.0" + }, + "metrics": { + "_dd.agent_psr": 1, + "_sampling_priority_v1": 1 + } + }]]