diff --git a/cozeloop-core/src/main/java/com/coze/loop/auth/Auth.java b/cozeloop-core/src/main/java/com/coze/loop/auth/Auth.java index 6458c26..23100bd 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/auth/Auth.java +++ b/cozeloop-core/src/main/java/com/coze/loop/auth/Auth.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.auth; /** diff --git a/cozeloop-core/src/main/java/com/coze/loop/auth/JWTOAuthAuth.java b/cozeloop-core/src/main/java/com/coze/loop/auth/JWTOAuthAuth.java index d5dc87d..a7b4405 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/auth/JWTOAuthAuth.java +++ b/cozeloop-core/src/main/java/com/coze/loop/auth/JWTOAuthAuth.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.auth; import com.coze.loop.exception.AuthException; diff --git a/cozeloop-core/src/main/java/com/coze/loop/auth/TokenAuth.java b/cozeloop-core/src/main/java/com/coze/loop/auth/TokenAuth.java index 2753e06..a162592 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/auth/TokenAuth.java +++ b/cozeloop-core/src/main/java/com/coze/loop/auth/TokenAuth.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.auth; import com.coze.loop.exception.AuthException; diff --git a/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClient.java b/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClient.java index 5bd00eb..0204c9d 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClient.java +++ b/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClient.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.client; import com.coze.loop.entity.ExecuteParam; diff --git a/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClientBuilder.java b/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClientBuilder.java index 921bed2..61b6ac6 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClientBuilder.java +++ b/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClientBuilder.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.client; import com.coze.loop.auth.Auth; diff --git a/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClientImpl.java b/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClientImpl.java index 33833f1..72c4e81 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClientImpl.java +++ b/cozeloop-core/src/main/java/com/coze/loop/client/CozeLoopClientImpl.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.client; import com.coze.loop.entity.ExecuteParam; diff --git a/cozeloop-core/src/main/java/com/coze/loop/config/CozeLoopConfig.java b/cozeloop-core/src/main/java/com/coze/loop/config/CozeLoopConfig.java index 2bbc91c..c54818a 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/config/CozeLoopConfig.java +++ b/cozeloop-core/src/main/java/com/coze/loop/config/CozeLoopConfig.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.config; import com.coze.loop.http.HttpConfig; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/ContentPart.java b/cozeloop-core/src/main/java/com/coze/loop/entity/ContentPart.java index 9f9935d..1200d1d 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/ContentPart.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/ContentPart.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/ContentType.java b/cozeloop-core/src/main/java/com/coze/loop/entity/ContentType.java index 227eecc..bdb61e9 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/ContentType.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/ContentType.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonValue; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/ExecuteParam.java b/cozeloop-core/src/main/java/com/coze/loop/entity/ExecuteParam.java index 366ad36..6b60a28 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/ExecuteParam.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/ExecuteParam.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/ExecuteResult.java b/cozeloop-core/src/main/java/com/coze/loop/entity/ExecuteResult.java index f5d2add..569b13f 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/ExecuteResult.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/ExecuteResult.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/LLMConfig.java b/cozeloop-core/src/main/java/com/coze/loop/entity/LLMConfig.java index 9ac6cde..49f71a6 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/LLMConfig.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/LLMConfig.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/Message.java b/cozeloop-core/src/main/java/com/coze/loop/entity/Message.java index 623dbc0..8f60fdf 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/Message.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/Message.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/Prompt.java b/cozeloop-core/src/main/java/com/coze/loop/entity/Prompt.java index d72dc1c..252e426 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/Prompt.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/Prompt.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/PromptTemplate.java b/cozeloop-core/src/main/java/com/coze/loop/entity/PromptTemplate.java index c210157..8a219cf 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/PromptTemplate.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/PromptTemplate.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/Role.java b/cozeloop-core/src/main/java/com/coze/loop/entity/Role.java index 9b48a44..d333c07 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/Role.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/Role.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonValue; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/TemplateType.java b/cozeloop-core/src/main/java/com/coze/loop/entity/TemplateType.java index 0d83a65..f60d2cc 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/TemplateType.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/TemplateType.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonValue; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/TokenUsage.java b/cozeloop-core/src/main/java/com/coze/loop/entity/TokenUsage.java index c72e6d6..67064b6 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/TokenUsage.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/TokenUsage.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/Tool.java b/cozeloop-core/src/main/java/com/coze/loop/entity/Tool.java index 7a9e98c..ac55aab 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/Tool.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/Tool.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/ToolCall.java b/cozeloop-core/src/main/java/com/coze/loop/entity/ToolCall.java index 703f322..4dde32c 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/ToolCall.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/ToolCall.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/ToolCallConfig.java b/cozeloop-core/src/main/java/com/coze/loop/entity/ToolCallConfig.java index e6a6668..e02cf89 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/ToolCallConfig.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/ToolCallConfig.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/UploadFile.java b/cozeloop-core/src/main/java/com/coze/loop/entity/UploadFile.java index 294925f..bb1da1b 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/UploadFile.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/UploadFile.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; /** diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/UploadSpan.java b/cozeloop-core/src/main/java/com/coze/loop/entity/UploadSpan.java index fb7f709..db666d6 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/UploadSpan.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/UploadSpan.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/VariableDef.java b/cozeloop-core/src/main/java/com/coze/loop/entity/VariableDef.java index a9aa68d..c44164b 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/VariableDef.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/VariableDef.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/cozeloop-core/src/main/java/com/coze/loop/entity/VariableType.java b/cozeloop-core/src/main/java/com/coze/loop/entity/VariableType.java index ddc56e1..4ebdf4b 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/entity/VariableType.java +++ b/cozeloop-core/src/main/java/com/coze/loop/entity/VariableType.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.entity; import com.fasterxml.jackson.annotation.JsonValue; diff --git a/cozeloop-core/src/main/java/com/coze/loop/exception/AuthException.java b/cozeloop-core/src/main/java/com/coze/loop/exception/AuthException.java index e38c0b1..3490061 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/exception/AuthException.java +++ b/cozeloop-core/src/main/java/com/coze/loop/exception/AuthException.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.exception; /** diff --git a/cozeloop-core/src/main/java/com/coze/loop/exception/CozeLoopException.java b/cozeloop-core/src/main/java/com/coze/loop/exception/CozeLoopException.java index 198b255..5661579 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/exception/CozeLoopException.java +++ b/cozeloop-core/src/main/java/com/coze/loop/exception/CozeLoopException.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.exception; /** diff --git a/cozeloop-core/src/main/java/com/coze/loop/exception/ErrorCode.java b/cozeloop-core/src/main/java/com/coze/loop/exception/ErrorCode.java index 27b5805..b4c9d9e 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/exception/ErrorCode.java +++ b/cozeloop-core/src/main/java/com/coze/loop/exception/ErrorCode.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.exception; /** diff --git a/cozeloop-core/src/main/java/com/coze/loop/exception/ExportException.java b/cozeloop-core/src/main/java/com/coze/loop/exception/ExportException.java index de9450f..b1fc5ce 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/exception/ExportException.java +++ b/cozeloop-core/src/main/java/com/coze/loop/exception/ExportException.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.exception; /** diff --git a/cozeloop-core/src/main/java/com/coze/loop/exception/PromptException.java b/cozeloop-core/src/main/java/com/coze/loop/exception/PromptException.java index f871519..0f11888 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/exception/PromptException.java +++ b/cozeloop-core/src/main/java/com/coze/loop/exception/PromptException.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.exception; /** diff --git a/cozeloop-core/src/main/java/com/coze/loop/exception/TraceException.java b/cozeloop-core/src/main/java/com/coze/loop/exception/TraceException.java index 246811d..133d8d9 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/exception/TraceException.java +++ b/cozeloop-core/src/main/java/com/coze/loop/exception/TraceException.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.exception; /** diff --git a/cozeloop-core/src/main/java/com/coze/loop/http/AuthInterceptor.java b/cozeloop-core/src/main/java/com/coze/loop/http/AuthInterceptor.java index 420323c..1287d90 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/http/AuthInterceptor.java +++ b/cozeloop-core/src/main/java/com/coze/loop/http/AuthInterceptor.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.http; import com.coze.loop.auth.Auth; diff --git a/cozeloop-core/src/main/java/com/coze/loop/http/HttpClient.java b/cozeloop-core/src/main/java/com/coze/loop/http/HttpClient.java index c7da652..0fc5c28 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/http/HttpClient.java +++ b/cozeloop-core/src/main/java/com/coze/loop/http/HttpClient.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.http; import com.coze.loop.auth.Auth; diff --git a/cozeloop-core/src/main/java/com/coze/loop/http/HttpConfig.java b/cozeloop-core/src/main/java/com/coze/loop/http/HttpConfig.java index 806665d..32e66fc 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/http/HttpConfig.java +++ b/cozeloop-core/src/main/java/com/coze/loop/http/HttpConfig.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.http; /** diff --git a/cozeloop-core/src/main/java/com/coze/loop/http/LoggingInterceptor.java b/cozeloop-core/src/main/java/com/coze/loop/http/LoggingInterceptor.java index 3e5776e..b48f314 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/http/LoggingInterceptor.java +++ b/cozeloop-core/src/main/java/com/coze/loop/http/LoggingInterceptor.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.http; import okhttp3.Interceptor; diff --git a/cozeloop-core/src/main/java/com/coze/loop/http/RetryInterceptor.java b/cozeloop-core/src/main/java/com/coze/loop/http/RetryInterceptor.java index 8d66977..c5222a9 100644 --- a/cozeloop-core/src/main/java/com/coze/loop/http/RetryInterceptor.java +++ b/cozeloop-core/src/main/java/com/coze/loop/http/RetryInterceptor.java @@ -1,6 +1,3 @@ -// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates -// SPDX-License-Identifier: MIT - package com.coze.loop.http; import okhttp3.Interceptor; diff --git a/cozeloop-core/src/main/java/com/coze/loop/internal/IdGenerator.java b/cozeloop-core/src/main/java/com/coze/loop/internal/IdGenerator.java new file mode 100644 index 0000000..1b0e3ef --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/internal/IdGenerator.java @@ -0,0 +1,58 @@ +package com.coze.loop.internal; + +import java.security.SecureRandom; +import java.util.Random; + +/** + * ID generator utility class. + */ +public final class IdGenerator { + private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray(); + private static final Random RANDOM = new SecureRandom(); + + private IdGenerator() { + // Utility class + } + + /** + * Generate a 32-character hexadecimal trace ID. + * + * @return trace ID + */ + public static String generateTraceId() { + return generateHexString(32); + } + + /** + * Generate a 16-character hexadecimal span ID. + * + * @return span ID + */ + public static String generateSpanId() { + return generateHexString(16); + } + + /** + * Generate a hexadecimal string of specified length. + * + * @param length the length of the string + * @return hexadecimal string + */ + public static String generateHexString(int length) { + char[] buffer = new char[length]; + for (int i = 0; i < length; i++) { + buffer[i] = HEX_CHARS[RANDOM.nextInt(16)]; + } + return new String(buffer); + } + + /** + * Generate a UUID-like ID. + * + * @return UUID string + */ + public static String generateUuid() { + return java.util.UUID.randomUUID().toString(); + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/internal/JsonUtils.java b/cozeloop-core/src/main/java/com/coze/loop/internal/JsonUtils.java new file mode 100644 index 0000000..dd18266 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/internal/JsonUtils.java @@ -0,0 +1,88 @@ +package com.coze.loop.internal; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** + * JSON utility class for serialization and deserialization. + */ +public final class JsonUtils { + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + + private JsonUtils() { + // Utility class + } + + /** + * Convert object to JSON string. + * + * @param obj the object to convert + * @return JSON string + */ + public static String toJson(Object obj) { + if (obj == null) { + return null; + } + if (obj instanceof String) { + return (String) obj; + } + try { + return MAPPER.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to convert object to JSON", e); + } + } + + /** + * Parse JSON string to object. + * + * @param json JSON string + * @param clazz target class + * @param type parameter + * @return parsed object + */ + public static T fromJson(String json, Class clazz) { + if (json == null || json.isEmpty()) { + return null; + } + try { + return MAPPER.readValue(json, clazz); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to parse JSON to " + clazz.getName(), e); + } + } + + /** + * Parse JSON string to object with TypeReference. + * + * @param json JSON string + * @param typeRef type reference + * @param type parameter + * @return parsed object + */ + public static T fromJson(String json, TypeReference typeRef) { + if (json == null || json.isEmpty()) { + return null; + } + try { + return MAPPER.readValue(json, typeRef); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to parse JSON", e); + } + } + + /** + * Get the ObjectMapper instance. + * + * @return ObjectMapper + */ + public static ObjectMapper getMapper() { + return MAPPER; + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/internal/ValidationUtils.java b/cozeloop-core/src/main/java/com/coze/loop/internal/ValidationUtils.java new file mode 100644 index 0000000..ab09cf8 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/internal/ValidationUtils.java @@ -0,0 +1,84 @@ +package com.coze.loop.internal; + +import com.coze.loop.exception.CozeLoopException; +import com.coze.loop.exception.ErrorCode; + +/** + * Validation utility class. + */ +public final class ValidationUtils { + + private ValidationUtils() { + // Utility class + } + + /** + * Check if a string is not null and not empty. + * + * @param value the string to check + * @param paramName the parameter name for error message + * @throws CozeLoopException if validation fails + */ + public static void requireNonEmpty(String value, String paramName) { + if (value == null || value.trim().isEmpty()) { + throw new CozeLoopException(ErrorCode.INVALID_PARAM, + paramName + " must not be null or empty"); + } + } + + /** + * Check if an object is not null. + * + * @param value the object to check + * @param paramName the parameter name for error message + * @throws CozeLoopException if validation fails + */ + public static void requireNonNull(Object value, String paramName) { + if (value == null) { + throw new CozeLoopException(ErrorCode.INVALID_PARAM, + paramName + " must not be null"); + } + } + + /** + * Check if a number is positive. + * + * @param value the number to check + * @param paramName the parameter name for error message + * @throws CozeLoopException if validation fails + */ + public static void requirePositive(long value, String paramName) { + if (value <= 0) { + throw new CozeLoopException(ErrorCode.INVALID_PARAM, + paramName + " must be positive"); + } + } + + /** + * Check if a number is non-negative. + * + * @param value the number to check + * @param paramName the parameter name for error message + * @throws CozeLoopException if validation fails + */ + public static void requireNonNegative(long value, String paramName) { + if (value < 0) { + throw new CozeLoopException(ErrorCode.INVALID_PARAM, + paramName + " must be non-negative"); + } + } + + /** + * Check if a condition is true. + * + * @param condition the condition to check + * @param message the error message + * @throws CozeLoopException if condition is false + */ + public static void require(boolean condition, String message) { + if (!condition) { + throw new CozeLoopException(ErrorCode.INVALID_PARAM, message); + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/prompt/GetPromptParam.java b/cozeloop-core/src/main/java/com/coze/loop/prompt/GetPromptParam.java new file mode 100644 index 0000000..728a62a --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/prompt/GetPromptParam.java @@ -0,0 +1,65 @@ +package com.coze.loop.prompt; + +/** + * Parameters for getting a prompt. + */ +public class GetPromptParam { + private String promptKey; + private String version; + private String label; + + public GetPromptParam() { + } + + public String getPromptKey() { + return promptKey; + } + + public void setPromptKey(String promptKey) { + this.promptKey = promptKey; + } + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private final GetPromptParam param = new GetPromptParam(); + + public Builder promptKey(String promptKey) { + param.promptKey = promptKey; + return this; + } + + public Builder version(String version) { + param.version = version; + return this; + } + + public Builder label(String label) { + param.label = label; + return this; + } + + public GetPromptParam build() { + return param; + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/prompt/Jinja2TemplateEngine.java b/cozeloop-core/src/main/java/com/coze/loop/prompt/Jinja2TemplateEngine.java new file mode 100644 index 0000000..6bb3924 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/prompt/Jinja2TemplateEngine.java @@ -0,0 +1,47 @@ +package com.coze.loop.prompt; + +import com.coze.loop.exception.ErrorCode; +import com.coze.loop.exception.PromptException; +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.JinjavaConfig; +import com.hubspot.jinjava.interpret.RenderResult; + +import java.util.Map; + +/** + * Jinja2 template engine using JinJava library. + */ +public class Jinja2TemplateEngine implements TemplateEngine { + private final Jinjava jinjava; + + public Jinja2TemplateEngine() { + JinjavaConfig config = JinjavaConfig.newBuilder() + .withFailOnUnknownTokens(false) + .build(); + this.jinjava = new Jinjava(config); + } + + @Override + public String render(String template, Map variables) { + if (template == null || template.isEmpty()) { + return template; + } + + try { + RenderResult result = jinjava.renderForResult(template, variables); + + if (result.hasErrors()) { + throw new PromptException(ErrorCode.TEMPLATE_RENDER_ERROR, + "Jinja2 template render errors: " + result.getErrors()); + } + + return result.getOutput(); + } catch (PromptException e) { + throw e; + } catch (Exception e) { + throw new PromptException(ErrorCode.TEMPLATE_RENDER_ERROR, + "Failed to render Jinja2 template", e); + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/prompt/NormalTemplateEngine.java b/cozeloop-core/src/main/java/com/coze/loop/prompt/NormalTemplateEngine.java new file mode 100644 index 0000000..6e0c048 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/prompt/NormalTemplateEngine.java @@ -0,0 +1,53 @@ +package com.coze.loop.prompt; + +import com.coze.loop.exception.ErrorCode; +import com.coze.loop.exception.PromptException; +import org.apache.commons.text.StringSubstitutor; + +import java.util.HashMap; +import java.util.Map; + +/** + * Normal template engine using Apache Commons Text. + * Supports ${variable} and {{variable}} placeholders. + */ +public class NormalTemplateEngine implements TemplateEngine { + + @Override + public String render(String template, Map variables) { + if (template == null || template.isEmpty()) { + return template; + } + + try { + // Handle null variables + if (variables == null) { + variables = new HashMap<>(); + } + + // Convert all values to strings + Map stringVars = new HashMap<>(); + for (Map.Entry entry : variables.entrySet()) { + String value = entry.getValue() != null ? + String.valueOf(entry.getValue()) : ""; + stringVars.put(entry.getKey(), value); + } + + // Replace ${variable} style + StringSubstitutor substitutor = new StringSubstitutor(stringVars); + String result = substitutor.replace(template); + + // Replace {{variable}} style + for (Map.Entry entry : stringVars.entrySet()) { + String placeholder = "{{" + entry.getKey() + "}}"; + result = result.replace(placeholder, entry.getValue()); + } + + return result; + } catch (Exception e) { + throw new PromptException(ErrorCode.TEMPLATE_RENDER_ERROR, + "Failed to render normal template", e); + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/prompt/PromptCache.java b/cozeloop-core/src/main/java/com/coze/loop/prompt/PromptCache.java new file mode 100644 index 0000000..4e49fd2 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/prompt/PromptCache.java @@ -0,0 +1,160 @@ +package com.coze.loop.prompt; + +import com.coze.loop.entity.Prompt; +import com.github.benmanes.caffeine.cache.AsyncLoadingCache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +/** + * Cache for prompts using Caffeine. + * Supports LRU eviction and automatic refresh. + */ +public class PromptCache { + private static final Logger logger = LoggerFactory.getLogger(PromptCache.class); + + private final AsyncLoadingCache cache; + + /** + * Create a PromptCache with custom configuration. + * + * @param config cache configuration + * @param loader function to load prompt when not in cache + */ + public PromptCache(PromptCacheConfig config, Function loader) { + this.cache = Caffeine.newBuilder() + .maximumSize(config.getMaxSize()) + .expireAfterWrite(config.getExpireAfterWriteMinutes(), TimeUnit.MINUTES) + .refreshAfterWrite(config.getRefreshAfterWriteMinutes(), TimeUnit.MINUTES) + .recordStats() + .buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> { + logger.debug("Loading prompt from source: {}", key); + return loader.apply(key); + }, executor)); + } + + /** + * Get prompt from cache, loading if necessary. + * + * @param key the cache key + * @return CompletableFuture of prompt + */ + public CompletableFuture get(String key) { + return cache.get(key); + } + + /** + * Get prompt from cache synchronously. + * + * @param key the cache key + * @return prompt or null if not found + */ + public Prompt getSync(String key) { + try { + return cache.get(key).join(); + } catch (Exception e) { + logger.error("Error getting prompt from cache: {}", key, e); + return null; + } + } + + /** + * Put prompt into cache. + * + * @param key the cache key + * @param prompt the prompt + */ + public void put(String key, Prompt prompt) { + cache.put(key, CompletableFuture.completedFuture(prompt)); + } + + /** + * Invalidate a cache entry. + * + * @param key the cache key + */ + public void invalidate(String key) { + cache.synchronous().invalidate(key); + } + + /** + * Invalidate all cache entries. + */ + public void invalidateAll() { + cache.synchronous().invalidateAll(); + } + + /** + * Get cache statistics. + * + * @return cache stats + */ + public com.github.benmanes.caffeine.cache.stats.CacheStats stats() { + return cache.synchronous().stats(); + } + + /** + * Configuration for prompt cache. + */ + public static class PromptCacheConfig { + private long maxSize = 1000; + private long expireAfterWriteMinutes = 60; + private long refreshAfterWriteMinutes = 30; + + public long getMaxSize() { + return maxSize; + } + + public void setMaxSize(long maxSize) { + this.maxSize = maxSize; + } + + public long getExpireAfterWriteMinutes() { + return expireAfterWriteMinutes; + } + + public void setExpireAfterWriteMinutes(long expireAfterWriteMinutes) { + this.expireAfterWriteMinutes = expireAfterWriteMinutes; + } + + public long getRefreshAfterWriteMinutes() { + return refreshAfterWriteMinutes; + } + + public void setRefreshAfterWriteMinutes(long refreshAfterWriteMinutes) { + this.refreshAfterWriteMinutes = refreshAfterWriteMinutes; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private final PromptCacheConfig config = new PromptCacheConfig(); + + public Builder maxSize(long size) { + config.maxSize = size; + return this; + } + + public Builder expireAfterWriteMinutes(long minutes) { + config.expireAfterWriteMinutes = minutes; + return this; + } + + public Builder refreshAfterWriteMinutes(long minutes) { + config.refreshAfterWriteMinutes = minutes; + return this; + } + + public PromptCacheConfig build() { + return config; + } + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/prompt/PromptFormatter.java b/cozeloop-core/src/main/java/com/coze/loop/prompt/PromptFormatter.java new file mode 100644 index 0000000..0844202 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/prompt/PromptFormatter.java @@ -0,0 +1,110 @@ +package com.coze.loop.prompt; + +import com.coze.loop.entity.*; +import com.coze.loop.exception.ErrorCode; +import com.coze.loop.exception.PromptException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Formatter for prompt messages. + * Handles template rendering and variable substitution. + */ +public class PromptFormatter { + private static final Logger logger = LoggerFactory.getLogger(PromptFormatter.class); + + private final TemplateEngine normalEngine; + private final TemplateEngine jinja2Engine; + private final VariableValidator validator; + + public PromptFormatter() { + this.normalEngine = new NormalTemplateEngine(); + this.jinja2Engine = new Jinja2TemplateEngine(); + this.validator = new VariableValidator(); + } + + /** + * Format prompt messages with variables. + * + * @param prompt the prompt + * @param variables the variables to substitute + * @return formatted messages + */ + public List format(Prompt prompt, Map variables) { + if (prompt == null || prompt.getPromptTemplate() == null) { + throw new PromptException(ErrorCode.INVALID_PARAM, "Prompt or template is null"); + } + + PromptTemplate template = prompt.getPromptTemplate(); + + // Validate variables + if (template.getVariableDefs() != null) { + validator.validate(variables, template.getVariableDefs()); + } + + // Deep copy messages to avoid modifying original + List messages = new ArrayList<>(); + if (template.getMessages() != null) { + for (Message msg : template.getMessages()) { + messages.add(msg.deepCopy()); + } + } + + // Determine template type + TemplateType templateType = template.getTemplateType(); + if (templateType == null) { + templateType = TemplateType.NORMAL; + } + + // Select appropriate engine + TemplateEngine engine = templateType == TemplateType.JINJA2 ? + jinja2Engine : normalEngine; + + // Format each message + for (Message message : messages) { + formatMessage(message, variables, engine); + } + + return messages; + } + + /** + * Format a single message. + */ + private void formatMessage(Message message, Map variables, + TemplateEngine engine) { + // Format content + if (message.getContent() != null && !message.getContent().isEmpty()) { + String formatted = engine.render(message.getContent(), variables); + message.setContent(formatted); + } + + // Format parts + if (message.getParts() != null) { + for (ContentPart part : message.getParts()) { + formatContentPart(part, variables, engine); + } + } + } + + /** + * Format a content part. + */ + private void formatContentPart(ContentPart part, Map variables, + TemplateEngine engine) { + if (part.getType() == ContentType.TEXT && part.getText() != null) { + String formatted = engine.render(part.getText(), variables); + part.setText(formatted); + } else if (part.getType() == ContentType.MULTI_PART_VARIABLE) { + // Handle multi-part variable + // This is a placeholder for a variable that should be expanded + // The actual implementation depends on your requirements + logger.debug("Multi-part variable found in content part"); + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/prompt/PromptProvider.java b/cozeloop-core/src/main/java/com/coze/loop/prompt/PromptProvider.java new file mode 100644 index 0000000..a5ed932 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/prompt/PromptProvider.java @@ -0,0 +1,657 @@ +package com.coze.loop.prompt; + +import com.coze.loop.entity.ContentPart; +import com.coze.loop.entity.ExecuteParam; +import com.coze.loop.entity.ExecuteResult; +import com.coze.loop.entity.Message; +import com.coze.loop.entity.Prompt; +import com.coze.loop.entity.TokenUsage; +import com.coze.loop.exception.ErrorCode; +import com.coze.loop.exception.PromptException; +import com.coze.loop.http.HttpClient; +import com.coze.loop.internal.JsonUtils; +import com.coze.loop.internal.ValidationUtils; +import com.coze.loop.stream.SSEDecoder; +import com.coze.loop.stream.SSEParser; +import com.coze.loop.stream.ServerSentEvent; +import com.coze.loop.stream.StreamReader; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Provider for prompt operations: fetch, cache, and format. + */ +public class PromptProvider { + private static final Logger logger = LoggerFactory.getLogger(PromptProvider.class); + + private final HttpClient httpClient; + private final String promptEndpoint; + private final String executeEndpoint; + private final String executeStreamingEndpoint; + private final String workspaceId; + private final PromptCache cache; + private final PromptFormatter formatter; + // Map cacheKey to GetPromptParam for fetching from server + private final Map paramMap = new ConcurrentHashMap<>(); + // Singleflight map for preventing duplicate requests + private final Map> singleflightMap = new ConcurrentHashMap<>(); + + public PromptProvider(HttpClient httpClient, + String promptEndpoint, + String workspaceId, + PromptCache.PromptCacheConfig cacheConfig) { + this(httpClient, promptEndpoint, null, null, workspaceId, cacheConfig); + } + + public PromptProvider(HttpClient httpClient, + String promptEndpoint, + String executeEndpoint, + String executeStreamingEndpoint, + String workspaceId, + PromptCache.PromptCacheConfig cacheConfig) { + this.httpClient = httpClient; + this.promptEndpoint = promptEndpoint; + this.executeEndpoint = executeEndpoint; + this.executeStreamingEndpoint = executeStreamingEndpoint; + this.workspaceId = workspaceId; + this.formatter = new PromptFormatter(); + + // Initialize cache with this provider as the loader + this.cache = new PromptCache(cacheConfig, this::fetchPromptFromServer); + } + + /** + * Get a prompt (with caching). + * + * @param param the parameters for getting prompt + * @return prompt + */ + public Prompt getPrompt(GetPromptParam param) { + ValidationUtils.requireNonEmpty(param.getPromptKey(), "promptKey"); + + String cacheKey = buildCacheKey(param); + + // Store param mapping for fetchPromptFromServer to use + paramMap.put(cacheKey, param); + + try { + Prompt prompt = cache.getSync(cacheKey); + if (prompt == null) { + throw new PromptException(ErrorCode.PROMPT_NOT_FOUND, + "Failed to get prompt: " + param.getPromptKey() + ". Cache returned null."); + } + return prompt; + } catch (PromptException e) { + throw e; + } catch (Exception e) { + throw new PromptException(ErrorCode.PROMPT_NOT_FOUND, + "Failed to get prompt: " + param.getPromptKey(), e); + } finally { + // Clean up param mapping after use (optional, can keep for cache refresh) + // paramMap.remove(cacheKey); + } + } + + /** + * Format prompt with variables. + * + * @param prompt the prompt + * @param variables the variables to substitute + * @return formatted messages + */ + public List formatPrompt(Prompt prompt, Map variables) { + ValidationUtils.requireNonNull(prompt, "prompt"); + + if (variables == null) { + variables = new HashMap<>(); + } + + return formatter.format(prompt, variables); + } + + /** + * Get and format prompt in one call. + * + * @param param the parameters for getting prompt + * @param variables the variables to substitute + * @return formatted messages + */ + public List getAndFormat(GetPromptParam param, Map variables) { + Prompt prompt = getPrompt(param); + return formatPrompt(prompt, variables); + } + + /** + * Invalidate cache for a prompt. + * + * @param param the parameters identifying the prompt + */ + public void invalidateCache(GetPromptParam param) { + String cacheKey = buildCacheKey(param); + cache.invalidate(cacheKey); + } + + /** + * Invalidate all cached prompts. + */ + public void invalidateAllCache() { + cache.invalidateAll(); + } + + /** + * Fetch prompt from server (called by cache loader). + * This method is called by the cache when a prompt is not found in cache. + * Uses mget API with singleflight pattern to prevent duplicate requests. + * + * @param cacheKey the cache key + * @return prompt fetched from server + */ + private Prompt fetchPromptFromServer(String cacheKey) { + logger.info("Fetching prompt from server for cache key: {}", cacheKey); + + // Get parameters from map + GetPromptParam param = paramMap.get(cacheKey); + if (param == null) { + // Fallback: try to parse from cache key if param not found + logger.warn("Param not found in map for cache key: {}, attempting to parse from key", cacheKey); + param = parseParamFromCacheKey(cacheKey); + } + + // Make param effectively final for lambda + final GetPromptParam finalParam = param; + + // Build request for single query + List> queries = new ArrayList<>(); + Map query = new HashMap<>(); + query.put("prompt_key", finalParam.getPromptKey()); + if (finalParam.getVersion() != null && !finalParam.getVersion().isEmpty()) { + query.put("version", finalParam.getVersion()); + } + if (finalParam.getLabel() != null && !finalParam.getLabel().isEmpty()) { + query.put("label", finalParam.getLabel()); + } + queries.add(query); + + // Build request body for mget API + Map requestBody = new HashMap<>(); + requestBody.put("workspace_id", workspaceId); + requestBody.put("queries", queries); + + // Generate singleflight key (sorted JSON of request) + String singleflightKey = JsonUtils.toJson(requestBody); + + // Use singleflight pattern + CompletableFuture future = singleflightMap.computeIfAbsent(singleflightKey, key -> { + return CompletableFuture.supplyAsync(() -> { + try { + return doMPullPrompt(requestBody, finalParam); + } finally { + // Remove from singleflight map after completion + singleflightMap.remove(singleflightKey); + } + }); + }); + + try { + return future.get(); + } catch (Exception e) { + if (e.getCause() instanceof PromptException) { + throw (PromptException) e.getCause(); + } + throw new PromptException(ErrorCode.INTERNAL_ERROR, + "Failed to fetch prompt from server: " + param.getPromptKey(), e); + } + } + + /** + * Execute mget API request to fetch prompts. + * + * @param requestBody the request body + * @param param the original parameter (for logging) + * @return the fetched prompt + */ + private Prompt doMPullPrompt(Map requestBody, GetPromptParam param) { + try { + logger.debug("Requesting prompt from server: endpoint={}, body={}", promptEndpoint, requestBody); + + // Log request information including headers that will be sent + logger.info("=== Request Prompt Details ==="); + logger.info("URL: {}", promptEndpoint); + logger.info("Method: POST"); + logger.info("Headers:"); + logger.info(" Content-Type: application/json; charset=utf-8"); + logger.info(" User-Agent: CozeLoop-Java-SDK/1.0.0"); + logger.info(" Authorization: [configured by AuthInterceptor - see debug logs for details]"); + logger.info("Request Body: {}", JsonUtils.toJson(requestBody)); + logger.info("Prompt Key: {}", param.getPromptKey()); + if (param.getVersion() != null && !param.getVersion().isEmpty()) { + logger.info("Version: {}", param.getVersion()); + } + if (param.getLabel() != null && !param.getLabel().isEmpty()) { + logger.info("Label: {}", param.getLabel()); + } + + // Make HTTP request to fetch prompt using mget API + String response = httpClient.post(promptEndpoint, requestBody); + + if (response == null || response.isEmpty()) { + throw new PromptException(ErrorCode.PROMPT_NOT_FOUND, + "Empty response from server for prompt: " + param.getPromptKey()); + } + + logger.info("=== Response Received ==="); + logger.debug("Response body: {}", response); + + // Check if response is HTML (likely an error page or redirect) + String trimmedResponse = response.trim(); + if (trimmedResponse.startsWith(" 500 ? response.substring(0, 500) : response); + throw new PromptException(ErrorCode.INTERNAL_ERROR, + String.format("Server returned HTML instead of JSON. " + + "This usually indicates authentication failure or incorrect endpoint. " + + "Endpoint: %s, Prompt Key: %s", promptEndpoint, param.getPromptKey())); + } + + // Parse response + Map responseMap; + try { + @SuppressWarnings("unchecked") + Map parsed = (Map) JsonUtils.fromJson(response, Map.class); + responseMap = parsed; + } catch (Exception e) { + logger.error("Failed to parse response as JSON. Response preview (first 500 chars): {}", + response.length() > 500 ? response.substring(0, 500) : response); + throw new PromptException(ErrorCode.INTERNAL_ERROR, + String.format("Failed to parse response as JSON. " + + "This may indicate the server returned an error page. " + + "Endpoint: %s, Prompt Key: %s", promptEndpoint, param.getPromptKey()), e); + } + + if (responseMap == null) { + throw new PromptException(ErrorCode.INTERNAL_ERROR, + "Failed to parse response from server"); + } + + // Parse mget response format: {data: {items: [{query: {...}, prompt: {...}}]}} + if (responseMap.containsKey("data")) { + @SuppressWarnings("unchecked") + Map dataMap = (Map) responseMap.get("data"); + + if (dataMap != null && dataMap.containsKey("items")) { + @SuppressWarnings("unchecked") + List> items = (List>) dataMap.get("items"); + + if (items != null && !items.isEmpty()) { + // Get first item (should only be one for single query) + Map item = items.get(0); + if (item.containsKey("prompt")) { + Object promptObj = item.get("prompt"); + String promptJson = JsonUtils.toJson(promptObj); + Prompt prompt = JsonUtils.fromJson(promptJson, Prompt.class); + + if (prompt == null) { + throw new PromptException(ErrorCode.PROMPT_NOT_FOUND, + "Failed to deserialize prompt from response"); + } + + logger.info("Successfully fetched prompt from server: {}", param.getPromptKey()); + return prompt; + } + } + } + } + + throw new PromptException(ErrorCode.PROMPT_NOT_FOUND, + "Prompt not found in response. Response: " + response); + } catch (PromptException e) { + logger.error("Error fetching prompt from server for key: {}", param.getPromptKey(), e); + throw e; + } catch (Exception e) { + logger.error("Unexpected error fetching prompt from server for key: {}", param.getPromptKey(), e); + throw new PromptException(ErrorCode.INTERNAL_ERROR, + "Failed to fetch prompt from server: " + param.getPromptKey(), e); + } + } + + /** + * Parse GetPromptParam from cache key (fallback method). + * + * @param cacheKey the cache key + * @return GetPromptParam parsed from cache key + */ + private GetPromptParam parseParamFromCacheKey(String cacheKey) { + GetPromptParam param = new GetPromptParam(); + String[] parts = cacheKey.split(":", 3); + if (parts.length > 0 && !parts[0].isEmpty()) { + param.setPromptKey(parts[0]); + } + if (parts.length > 1 && !parts[1].isEmpty()) { + param.setVersion(parts[1]); + } + if (parts.length > 2 && !parts[2].isEmpty()) { + param.setLabel(parts[2]); + } + return param; + } + + /** + * Build cache key from parameters. + */ + private String buildCacheKey(GetPromptParam param) { + StringBuilder key = new StringBuilder(param.getPromptKey()); + key.append(":"); + key.append(param.getVersion() != null ? param.getVersion() : ""); + key.append(":"); + key.append(param.getLabel() != null ? param.getLabel() : ""); + return key.toString(); + } + + /** + * Execute a prompt. + * + * @param param the execution parameters + * @return the execution result + */ + public ExecuteResult execute(ExecuteParam param) { + ValidationUtils.requireNonNull(param, "param"); + ValidationUtils.requireNonEmpty(param.getPromptKey(), "promptKey"); + + if (executeEndpoint == null) { + throw new PromptException(ErrorCode.INTERNAL_ERROR, + "Execute endpoint is not configured"); + } + + try { + // Build execute request + Map requestBody = buildExecuteRequest(param); + + logger.info("=== Execute Prompt Request ==="); + logger.info("URL: {}", executeEndpoint); + logger.info("Method: POST"); + logger.info("Request Body: {}", JsonUtils.toJson(requestBody)); + + // Make HTTP request + String response = httpClient.post(executeEndpoint, requestBody); + + if (response == null || response.isEmpty()) { + throw new PromptException(ErrorCode.INTERNAL_ERROR, + "Empty response from server for execute request"); + } + + logger.info("=== Execute Prompt Response ==="); + logger.debug("Response body: {}", response); + + // Parse response + @SuppressWarnings("unchecked") + Map responseMap = JsonUtils.fromJson(response, Map.class); + + if (responseMap == null) { + throw new PromptException(ErrorCode.INTERNAL_ERROR, + "Failed to parse response from server"); + } + + // Parse response format: {data: {message: {...}, finish_reason: "...", usage: {...}}} + if (responseMap.containsKey("data")) { + @SuppressWarnings("unchecked") + Map dataMap = (Map) responseMap.get("data"); + + ExecuteResult result = new ExecuteResult(); + + if (dataMap != null) { + if (dataMap.containsKey("message")) { + Object messageObj = dataMap.get("message"); + String messageJson = JsonUtils.toJson(messageObj); + Message message = JsonUtils.fromJson(messageJson, Message.class); + result.setMessage(message); + } + + if (dataMap.containsKey("finish_reason")) { + Object finishReasonObj = dataMap.get("finish_reason"); + if (finishReasonObj != null) { + result.setFinishReason(finishReasonObj.toString()); + } + } + + if (dataMap.containsKey("usage")) { + Object usageObj = dataMap.get("usage"); + String usageJson = JsonUtils.toJson(usageObj); + TokenUsage usage = JsonUtils.fromJson(usageJson, TokenUsage.class); + result.setUsage(usage); + } + } + + return result; + } else { + throw new PromptException(ErrorCode.INTERNAL_ERROR, + "Invalid response format. Response: " + response); + } + } catch (PromptException e) { + throw e; + } catch (Exception e) { + throw new PromptException(ErrorCode.INTERNAL_ERROR, + "Failed to execute prompt: " + param.getPromptKey(), e); + } + } + + /** + * Execute a prompt with streaming response. + * + * @param param the execution parameters + * @return stream reader for ExecuteResult + */ + public StreamReader executeStreaming(ExecuteParam param) { + ValidationUtils.requireNonNull(param, "param"); + ValidationUtils.requireNonEmpty(param.getPromptKey(), "promptKey"); + + if (executeStreamingEndpoint == null) { + throw new PromptException(ErrorCode.INTERNAL_ERROR, + "Execute streaming endpoint is not configured"); + } + + try { + // Build execute request + Map requestBody = buildExecuteRequest(param); + + logger.info("=== Execute Streaming Prompt Request ==="); + logger.info("URL: {}", executeStreamingEndpoint); + logger.info("Method: POST"); + logger.info("Request Body: {}", JsonUtils.toJson(requestBody)); + + // Make streaming HTTP request + Response response = httpClient.postStream(executeStreamingEndpoint, requestBody); + + // Get response body stream + ResponseBody body = response.body(); + if (body == null) { + response.close(); + throw new PromptException(ErrorCode.INTERNAL_ERROR, + "Empty response body from server"); + } + + InputStream inputStream = body.byteStream(); + + // Create SSE decoder and parser + SSEDecoder decoder = new SSEDecoder(inputStream); + SSEParser parser = new ExecuteSSEParser(); + + // Create stream reader + return new StreamReader(decoder, parser) { + @Override + public void close() throws IOException { + super.close(); + response.close(); + } + }; + } catch (IOException e) { + throw new PromptException(ErrorCode.INTERNAL_ERROR, + "Failed to execute streaming prompt: " + param.getPromptKey(), e); + } + } + + /** + * Build execute request body from ExecuteParam. + * + * @param param the execution parameters + * @return the request body map + */ + private Map buildExecuteRequest(ExecuteParam param) { + Map requestBody = new HashMap<>(); + requestBody.put("workspace_id", workspaceId); + + // Build prompt_identifier + Map promptIdentifier = new HashMap<>(); + promptIdentifier.put("prompt_key", param.getPromptKey()); + if (param.getVersion() != null && !param.getVersion().isEmpty()) { + promptIdentifier.put("version", param.getVersion()); + } + if (param.getLabel() != null && !param.getLabel().isEmpty()) { + promptIdentifier.put("label", param.getLabel()); + } + requestBody.put("prompt_identifier", promptIdentifier); + + // Build variable_vals + if (param.getVariableVals() != null && !param.getVariableVals().isEmpty()) { + List> variableVals = new ArrayList<>(); + for (Map.Entry entry : param.getVariableVals().entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + if (value == null) { + throw new PromptException(ErrorCode.INVALID_PARAM, + "Variable value for key '" + key + "' is null"); + } + + Map variableVal = new HashMap<>(); + variableVal.put("key", key); + + // Handle different value types + if (value instanceof String) { + variableVal.put("value", value); + } else if (value instanceof Message) { + List messages = new ArrayList<>(); + messages.add((Message) value); + variableVal.put("placeholder_messages", messages); + } else if (value instanceof List) { + List list = (List) value; + if (!list.isEmpty()) { + Object first = list.get(0); + if (first instanceof Message) { + @SuppressWarnings("unchecked") + List messages = (List) value; + variableVal.put("placeholder_messages", messages); + } else if (first instanceof ContentPart) { + @SuppressWarnings("unchecked") + List parts = (List) value; + variableVal.put("multi_part_values", parts); + } else { + // Other types: serialize to JSON string + String jsonValue = JsonUtils.toJson(value); + variableVal.put("value", jsonValue); + } + } else { + // Empty list: serialize to JSON string + String jsonValue = JsonUtils.toJson(value); + variableVal.put("value", jsonValue); + } + } else if (value instanceof ContentPart) { + List parts = new ArrayList<>(); + parts.add((ContentPart) value); + variableVal.put("multi_part_values", parts); + } else { + // Other types: serialize to JSON string + String jsonValue = JsonUtils.toJson(value); + variableVal.put("value", jsonValue); + } + + variableVals.add(variableVal); + } + requestBody.put("variable_vals", variableVals); + } + + // Add messages if provided + if (param.getMessages() != null && !param.getMessages().isEmpty()) { + requestBody.put("messages", param.getMessages()); + } + + return requestBody; + } + + /** + * SSE Parser for ExecuteResult. + */ + private static class ExecuteSSEParser implements SSEParser { + @Override + public ExecuteResult parse(ServerSentEvent sse) throws Exception { + if (sse == null || !sse.hasData()) { + return null; + } + + // Parse streaming response + @SuppressWarnings("unchecked") + Map dataMap = JsonUtils.fromJson(sse.getData(), Map.class); + + if (dataMap == null) { + return null; + } + + ExecuteResult result = new ExecuteResult(); + + if (dataMap.containsKey("message")) { + Object messageObj = dataMap.get("message"); + String messageJson = JsonUtils.toJson(messageObj); + Message message = JsonUtils.fromJson(messageJson, Message.class); + result.setMessage(message); + } + + if (dataMap.containsKey("finish_reason")) { + Object finishReasonObj = dataMap.get("finish_reason"); + if (finishReasonObj != null) { + result.setFinishReason(finishReasonObj.toString()); + } + } + + if (dataMap.containsKey("usage")) { + Object usageObj = dataMap.get("usage"); + String usageJson = JsonUtils.toJson(usageObj); + TokenUsage usage = JsonUtils.fromJson(usageJson, TokenUsage.class); + result.setUsage(usage); + } + + return result; + } + + @Override + public Exception handleError(ServerSentEvent sse) { + if (sse == null || sse.getEvent() == null) { + return null; + } + + // Check if event field contains "error" (case-insensitive) + String event = sse.getEvent().toLowerCase(); + if (event.contains("error")) { + String errorMsg = sse.getData(); + if (errorMsg == null || errorMsg.isEmpty()) { + errorMsg = "Error event received without data"; + } + return new PromptException(ErrorCode.INTERNAL_ERROR, errorMsg); + } + + return null; + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/prompt/TemplateEngine.java b/cozeloop-core/src/main/java/com/coze/loop/prompt/TemplateEngine.java new file mode 100644 index 0000000..a99102d --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/prompt/TemplateEngine.java @@ -0,0 +1,18 @@ +package com.coze.loop.prompt; + +import java.util.Map; + +/** + * Template engine interface for rendering prompt templates. + */ +public interface TemplateEngine { + /** + * Render a template with variables. + * + * @param template the template string + * @param variables the variables to substitute + * @return rendered string + */ + String render(String template, Map variables); +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/prompt/VariableVal.java b/cozeloop-core/src/main/java/com/coze/loop/prompt/VariableVal.java new file mode 100644 index 0000000..af28320 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/prompt/VariableVal.java @@ -0,0 +1,98 @@ +package com.coze.loop.prompt; + +import com.coze.loop.entity.ContentPart; +import com.coze.loop.entity.Message; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Variable value for Execute request. + * Supports different types: string value, placeholder messages, or multipart values. + */ +public class VariableVal { + @JsonProperty("key") + private String key; + + @JsonProperty("value") + private String value; + + @JsonProperty("placeholder_messages") + private List placeholderMessages; + + @JsonProperty("multi_part_values") + private List multiPartValues; + + public VariableVal() { + } + + public VariableVal(String key) { + this.key = key; + } + + // Getters and Setters + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public List getPlaceholderMessages() { + return placeholderMessages; + } + + public void setPlaceholderMessages(List placeholderMessages) { + this.placeholderMessages = placeholderMessages; + } + + public List getMultiPartValues() { + return multiPartValues; + } + + public void setMultiPartValues(List multiPartValues) { + this.multiPartValues = multiPartValues; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private final VariableVal variableVal = new VariableVal(); + + public Builder key(String key) { + variableVal.key = key; + return this; + } + + public Builder value(String value) { + variableVal.value = value; + return this; + } + + public Builder placeholderMessages(List placeholderMessages) { + variableVal.placeholderMessages = placeholderMessages; + return this; + } + + public Builder multiPartValues(List multiPartValues) { + variableVal.multiPartValues = multiPartValues; + return this; + } + + public VariableVal build() { + return variableVal; + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/prompt/VariableValidator.java b/cozeloop-core/src/main/java/com/coze/loop/prompt/VariableValidator.java new file mode 100644 index 0000000..a9c8807 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/prompt/VariableValidator.java @@ -0,0 +1,91 @@ +package com.coze.loop.prompt; + +import com.coze.loop.entity.VariableDef; +import com.coze.loop.entity.VariableType; +import com.coze.loop.exception.ErrorCode; +import com.coze.loop.exception.PromptException; + +import java.util.List; +import java.util.Map; + +/** + * Validator for prompt variables. + */ +public class VariableValidator { + + /** + * Validate variables against their definitions. + * + * @param variables the variables to validate + * @param variableDefs the variable definitions + * @throws PromptException if validation fails + */ + public void validate(Map variables, List variableDefs) { + if (variableDefs == null || variableDefs.isEmpty()) { + return; + } + + for (VariableDef def : variableDefs) { + String key = def.getKey(); + Object value = variables.get(key); + + // Check if required variable is present + if (value == null) { + // For now, we don't enforce required variables + // You can add a "required" field to VariableDef if needed + continue; + } + + // Validate type + validateType(key, value, def.getType()); + } + } + + /** + * Validate variable type. + */ + private void validateType(String key, Object value, VariableType expectedType) { + if (expectedType == null) { + return; + } + + boolean valid = false; + + switch (expectedType) { + case STRING: + valid = value instanceof String; + break; + case BOOLEAN: + valid = value instanceof Boolean; + break; + case INTEGER: + valid = value instanceof Integer || value instanceof Long; + break; + case FLOAT: + valid = value instanceof Float || value instanceof Double; + break; + case OBJECT: + valid = value instanceof Map; + break; + case ARRAY_STRING: + case ARRAY_BOOLEAN: + case ARRAY_INTEGER: + case ARRAY_FLOAT: + case ARRAY_OBJECT: + valid = value instanceof List; + break; + case PLACEHOLDER: + case MULTI_PART: + // These types don't need strict validation + valid = true; + break; + } + + if (!valid) { + throw new PromptException(ErrorCode.INVALID_PARAM, + String.format("Variable '%s' has invalid type. Expected: %s, Got: %s", + key, expectedType.getValue(), value.getClass().getSimpleName())); + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/stream/SSEDecoder.java b/cozeloop-core/src/main/java/com/coze/loop/stream/SSEDecoder.java new file mode 100644 index 0000000..d2d9d2b --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/stream/SSEDecoder.java @@ -0,0 +1,116 @@ +package com.coze.loop.stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Decoder for Server-Sent Events (SSE). + * Parses SSE format from an InputStream. + */ +public class SSEDecoder { + private static final Logger logger = LoggerFactory.getLogger(SSEDecoder.class); + + private final BufferedReader reader; + + public SSEDecoder(InputStream inputStream) { + this.reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); + } + + /** + * Decode a single SSE event from the stream. + * + * @return the decoded SSE event, or null if EOF + * @throws IOException if reading fails + */ + public ServerSentEvent decodeEvent() throws IOException { + ServerSentEvent event = new ServerSentEvent(); + List dataLines = new ArrayList<>(); + + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + + // Empty line indicates end of event + if (line.isEmpty()) { + if (!dataLines.isEmpty() || event.getEvent() != null || + event.getId() != null || event.getRetry() != null) { + event.setData(String.join("\n", dataLines)); + return event; + } + continue; + } + + // Parse field:value format + int colonIndex = line.indexOf(':'); + if (colonIndex == -1) { + // Line without colon, treat as field name with empty value + processField(event, line, "", dataLines); + continue; + } + + String field = line.substring(0, colonIndex).trim(); + String value = line.substring(colonIndex + 1); + + // Remove leading space from value if present + if (value.startsWith(" ")) { + value = value.substring(1); + } + + processField(event, field, value, dataLines); + } + + // EOF reached + if (!dataLines.isEmpty() || event.getEvent() != null || + event.getId() != null || event.getRetry() != null) { + event.setData(String.join("\n", dataLines)); + return event; + } + + return null; // EOF, no more events + } + + /** + * Process a single SSE field. + */ + private void processField(ServerSentEvent event, String field, String value, List dataLines) { + switch (field.toLowerCase()) { + case "event": + event.setEvent(value); + break; + case "data": + dataLines.add(value); + break; + case "id": + event.setId(value); + break; + case "retry": + try { + event.setRetry(Integer.parseInt(value)); + } catch (NumberFormatException e) { + logger.debug("Invalid retry value: {}", value); + } + break; + default: + // Unknown field, ignore + break; + } + } + + /** + * Close the decoder and release resources. + */ + public void close() throws IOException { + if (reader != null) { + reader.close(); + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/stream/SSEParser.java b/cozeloop-core/src/main/java/com/coze/loop/stream/SSEParser.java new file mode 100644 index 0000000..19169c9 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/stream/SSEParser.java @@ -0,0 +1,26 @@ +package com.coze.loop.stream; + +/** + * Parser interface for converting SSE events into specific types. + * + * @param the type to parse SSE events into + */ +public interface SSEParser { + /** + * Parse an SSE event into the target type. + * + * @param sse the SSE event to parse + * @return the parsed object + * @throws Exception if parsing fails + */ + T parse(ServerSentEvent sse) throws Exception; + + /** + * Handle error events from SSE stream. + * + * @param sse the SSE event that may contain an error + * @return an exception if this is an error event, null otherwise + */ + Exception handleError(ServerSentEvent sse); +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/stream/ServerSentEvent.java b/cozeloop-core/src/main/java/com/coze/loop/stream/ServerSentEvent.java new file mode 100644 index 0000000..c95ae31 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/stream/ServerSentEvent.java @@ -0,0 +1,64 @@ +package com.coze.loop.stream; + +/** + * Represents a Server-Sent Event (SSE). + */ +public class ServerSentEvent { + private String event; + private String data; + private String id; + private Integer retry; + + public ServerSentEvent() { + } + + public ServerSentEvent(String event, String data, String id, Integer retry) { + this.event = event; + this.data = data; + this.id = id; + this.retry = retry; + } + + // Getters and Setters + public String getEvent() { + return event; + } + + public void setEvent(String event) { + this.event = event; + } + + public String getData() { + return data; + } + + public void setData(String data) { + this.data = data; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public Integer getRetry() { + return retry; + } + + public void setRetry(Integer retry) { + this.retry = retry; + } + + /** + * Check if this event has data. + * + * @return true if data is not null and not empty + */ + public boolean hasData() { + return data != null && !data.isEmpty(); + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/stream/StreamReader.java b/cozeloop-core/src/main/java/com/coze/loop/stream/StreamReader.java new file mode 100644 index 0000000..37113b1 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/stream/StreamReader.java @@ -0,0 +1,91 @@ +package com.coze.loop.stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.Closeable; +import java.io.IOException; + +/** + * Stream reader for reading typed objects from an SSE stream. + * + * @param the type of objects to read + */ +public class StreamReader implements Closeable { + private static final Logger logger = LoggerFactory.getLogger(StreamReader.class); + + private final SSEDecoder decoder; + private final SSEParser parser; + private boolean closed = false; + + public StreamReader(SSEDecoder decoder, SSEParser parser) { + this.decoder = decoder; + this.parser = parser; + } + + /** + * Receive the next item from the stream. + * + * @return the next item, or null if stream ended + * @throws Exception if reading or parsing fails + */ + public T recv() throws Exception { + if (closed) { + throw new IllegalStateException("Stream reader is closed"); + } + + while (true) { + ServerSentEvent sse = decoder.decodeEvent(); + if (sse == null) { + // Stream ended + close(); + return null; + } + + // Check for error events first + Exception error = parser.handleError(sse); + if (error != null) { + close(); + throw error; + } + + // Skip empty data + if (!sse.hasData()) { + continue; + } + + // Parse the event + try { + T result = parser.parse(sse); + if (result != null) { + return result; + } + // Continue to next event if parsing returned null + } catch (Exception e) { + logger.debug("Failed to parse SSE event, continuing: {}", e.getMessage()); + // Continue to next event for parsing errors + continue; + } + } + } + + /** + * Check if the stream is closed. + * + * @return true if closed + */ + public boolean isClosed() { + return closed; + } + + @Override + public void close() throws IOException { + if (!closed) { + closed = true; + if (decoder != null) { + decoder.close(); + } + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/trace/CozeLoopSpan.java b/cozeloop-core/src/main/java/com/coze/loop/trace/CozeLoopSpan.java new file mode 100644 index 0000000..1a3e3a4 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/trace/CozeLoopSpan.java @@ -0,0 +1,437 @@ +package com.coze.loop.trace; + +import com.coze.loop.internal.JsonUtils; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; + +import java.util.Map; + +/** + * Wrapper for OpenTelemetry Span that provides CozeLoop-specific methods. + * + *

This class wraps OpenTelemetry's {@link Span} and provides: + *

    + *
  • CozeLoop-specific convenience methods (setInput, setOutput, setModel, etc.)
  • + *
  • Automatic scope management via try-with-resources
  • + *
  • Access to underlying OpenTelemetry Span for advanced usage
  • + *
+ * + *

The span is automatically made current in the OpenTelemetry context when created, + * allowing child spans to automatically inherit the parent context. + * + *

Example usage: + *

{@code
+ * try (CozeLoopSpan span = client.startSpan("operation", "custom")) {
+ *     span.setInput("input data");
+ *     span.setOutput("output data");
+ *     span.addEvent("important-event");
+ * }
+ * }
+ * + *

For advanced OpenTelemetry features, you can access the underlying Span: + *

{@code
+ * CozeLoopSpan cozeSpan = client.startSpan("operation", "custom");
+ * Span otelSpan = cozeSpan.getSpan();
+ * // Use OpenTelemetry APIs directly
+ * }
+ */ +public class CozeLoopSpan implements AutoCloseable { + private final Span span; + private final Scope scope; + + /** + * Create a new CozeLoopSpan wrapper. + * + *

The span is automatically made current in the OpenTelemetry context, + * which enables automatic context propagation to child spans. + * + * @param span the underlying OpenTelemetry Span + * @param scope the scope that makes this span current in the context + */ + public CozeLoopSpan(Span span, Scope scope) { + this.span = span; + this.scope = scope; + } + + /** + * Set the input for this span. + * + * @param input the input object + * @return this span + */ + public CozeLoopSpan setInput(Object input) { + if (input != null) { + String inputStr = input instanceof String ? + (String) input : JsonUtils.toJson(input); + span.setAttribute(AttributeKey.stringKey("cozeloop.input"), inputStr); + } + return this; + } + + /** + * Set the output for this span. + * + * @param output the output object + * @return this span + */ + public CozeLoopSpan setOutput(Object output) { + if (output != null) { + String outputStr = output instanceof String ? + (String) output : JsonUtils.toJson(output); + span.setAttribute(AttributeKey.stringKey("cozeloop.output"), outputStr); + } + return this; + } + + /** + * Set the error for this span. + * + * @param error the error/exception + * @return this span + */ + public CozeLoopSpan setError(Throwable error) { + if (error != null) { + span.setStatus(StatusCode.ERROR, error.getMessage()); + span.recordException(error); + } + return this; + } + + /** + * Set status code (0=OK, 1=ERROR). + * + * @param statusCode the status code + * @return this span + */ + public CozeLoopSpan setStatusCode(int statusCode) { + if (statusCode == 0) { + span.setStatus(StatusCode.OK); + } else { + span.setStatus(StatusCode.ERROR); + } + return this; + } + + /** + * Set model provider (e.g., "openai", "anthropic"). + * + * @param provider the model provider + * @return this span + */ + public CozeLoopSpan setModelProvider(String provider) { + if (provider != null) { + span.setAttribute(AttributeKey.stringKey("llm.provider"), provider); + } + return this; + } + + /** + * Set model name. + * + * @param model the model name + * @return this span + */ + public CozeLoopSpan setModel(String model) { + if (model != null) { + span.setAttribute(AttributeKey.stringKey("llm.model"), model); + } + return this; + } + + /** + * Set input tokens. + * + * @param tokens the number of input tokens + * @return this span + */ + public CozeLoopSpan setInputTokens(long tokens) { + span.setAttribute(AttributeKey.longKey("llm.input_tokens"), tokens); + return this; + } + + /** + * Set output tokens. + * + * @param tokens the number of output tokens + * @return this span + */ + public CozeLoopSpan setOutputTokens(long tokens) { + span.setAttribute(AttributeKey.longKey("llm.output_tokens"), tokens); + return this; + } + + /** + * Set total tokens. + * + * @param tokens the total number of tokens + * @return this span + */ + public CozeLoopSpan setTotalTokens(long tokens) { + span.setAttribute(AttributeKey.longKey("llm.total_tokens"), tokens); + return this; + } + + /** + * Set custom attribute (string). + * + * @param key the attribute key + * @param value the attribute value + * @return this span + */ + public CozeLoopSpan setAttribute(String key, String value) { + if (key != null && value != null) { + span.setAttribute(AttributeKey.stringKey(key), value); + } + return this; + } + + /** + * Set custom attribute (long). + * + * @param key the attribute key + * @param value the attribute value + * @return this span + */ + public CozeLoopSpan setAttribute(String key, long value) { + if (key != null) { + span.setAttribute(AttributeKey.longKey(key), value); + } + return this; + } + + /** + * Set custom attribute (double). + * + * @param key the attribute key + * @param value the attribute value + * @return this span + */ + public CozeLoopSpan setAttribute(String key, double value) { + if (key != null) { + span.setAttribute(AttributeKey.doubleKey(key), value); + } + return this; + } + + /** + * Set custom attribute (boolean). + * + * @param key the attribute key + * @param value the attribute value + * @return this span + */ + public CozeLoopSpan setAttribute(String key, boolean value) { + if (key != null) { + span.setAttribute(AttributeKey.booleanKey(key), value); + } + return this; + } + + /** + * Add an event to this span. + * + *

Events are timestamped annotations on a span that represent something + * that happened during the span's lifetime. They are useful for marking + * important milestones or state changes. + * + *

Example: + *

{@code
+     * span.addEvent("operation-started");
+     * span.addEvent("data-processed");
+     * span.addEvent("operation-completed");
+     * }
+ * + * @param eventName the name of the event + * @return this span + */ + public CozeLoopSpan addEvent(String eventName) { + if (eventName != null) { + span.addEvent(eventName); + } + return this; + } + + /** + * Add an event with attributes to this span. + * + *

Events are timestamped annotations on a span. This method allows you to + * add an event with associated attributes. The attributes are set on the span + * with an "event." prefix to distinguish them from regular span attributes. + * + *

Example: + *

{@code
+     * Map eventAttrs = new HashMap<>();
+     * eventAttrs.put("response_length", 150L);
+     * eventAttrs.put("model", "gpt-4");
+     * span.addEvent("llm-response-received", eventAttrs);
+     * }
+ * + *

For more advanced event attributes, you can use the underlying OpenTelemetry Span: + *

{@code
+     * import io.opentelemetry.api.common.Attributes;
+     * span.getSpan().addEvent("event-name", 
+     *     Attributes.of(AttributeKey.stringKey("key"), "value"));
+     * }
+ * + * @param eventName the name of the event + * @param attributes the attributes to attach to the event (will be prefixed with "event.") + * @return this span + */ + public CozeLoopSpan addEvent(String eventName, Map attributes) { + if (eventName != null && attributes != null && !attributes.isEmpty()) { + // Set attributes on the span with "event." prefix to associate them with the event + for (Map.Entry entry : attributes.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + if (key == null || value == null) { + continue; + } + + // Set attributes with "event." prefix to associate with the event + String eventAttrKey = "event." + key; + if (value instanceof String) { + span.setAttribute(AttributeKey.stringKey(eventAttrKey), (String) value); + } else if (value instanceof Long) { + span.setAttribute(AttributeKey.longKey(eventAttrKey), (Long) value); + } else if (value instanceof Integer) { + span.setAttribute(AttributeKey.longKey(eventAttrKey), ((Integer) value).longValue()); + } else if (value instanceof Double) { + span.setAttribute(AttributeKey.doubleKey(eventAttrKey), (Double) value); + } else if (value instanceof Float) { + span.setAttribute(AttributeKey.doubleKey(eventAttrKey), ((Float) value).doubleValue()); + } else if (value instanceof Boolean) { + span.setAttribute(AttributeKey.booleanKey(eventAttrKey), (Boolean) value); + } else { + // Convert other types to string + span.setAttribute(AttributeKey.stringKey(eventAttrKey), String.valueOf(value)); + } + } + // Add the event + span.addEvent(eventName); + } + return this; + } + + /** + * Record an exception on this span. + * + *

This is a convenience method that records an exception as an event + * with exception details. It's equivalent to calling setError() but + * provides more detailed exception information. + * + *

Example: + *

{@code
+     * try {
+     *     // operation
+     * } catch (Exception e) {
+     *     span.recordException(e);
+     *     span.setStatusCode(1);
+     *     throw e;
+     * }
+     * }
+ * + * @param exception the exception to record + * @return this span + */ + public CozeLoopSpan recordException(Throwable exception) { + if (exception != null) { + span.recordException(exception); + } + return this; + } + + /** + * Get the current OpenTelemetry Context. + * + *

This is useful for accessing the current context, which includes: + *

    + *
  • Current span
  • + *
  • Baggage (key-value data propagated across services)
  • + *
  • Other context data
  • + *
+ * + *

Example: + *

{@code
+     * Context currentContext = span.getCurrentContext();
+     * // Use context for async operations or cross-service propagation
+     * }
+ * + * @return the current OpenTelemetry context + */ + public Context getCurrentContext() { + return Context.current(); + } + + /** + * Get the span context for this span. + * + *

The span context contains trace ID, span ID, trace flags, and trace state. + * It can be used to create links to this span from other spans, or to + * propagate trace context across service boundaries. + * + *

Example: + *

{@code
+     * SpanContext spanContext = span.getSpanContext();
+     * // Pass spanContext to another service or span
+     * }
+ * + * @return the span context + */ + public io.opentelemetry.api.trace.SpanContext getSpanContext() { + return span.getSpanContext(); + } + + /** + * Get the underlying OpenTelemetry Span. + * + *

This method provides direct access to the underlying OpenTelemetry Span + * for advanced use cases that require OpenTelemetry-specific APIs not + * exposed through CozeLoopSpan. + * + *

Example: + *

{@code
+     * Span otelSpan = span.getSpan();
+     * // Use OpenTelemetry APIs directly
+     * otelSpan.setAttribute(AttributeKey.stringKey("custom"), "value");
+     * }
+ * + * @return the underlying OpenTelemetry span + */ + public Span getSpan() { + return span; + } + + /** + * Get the scope associated with this span. + * + *

The scope is what makes this span "current" in the OpenTelemetry context. + * When the scope is closed, the span is no longer current. This is automatically + * handled by the try-with-resources pattern, but can be accessed for advanced use cases. + * + * @return the scope + */ + public Scope getScope() { + return scope; + } + + /** + * End the span and close the scope. + * + *

This method is automatically called when using try-with-resources. + * It ends the span (marking it as finished) and closes the scope (removing + * it from the current context). The span will then be processed by the + * BatchSpanProcessor and eventually exported to CozeLoop platform. + */ + @Override + public void close() { + try { + span.end(); + } finally { + scope.close(); + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/trace/CozeLoopSpanExporter.java b/cozeloop-core/src/main/java/com/coze/loop/trace/CozeLoopSpanExporter.java new file mode 100644 index 0000000..78eaf0a --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/trace/CozeLoopSpanExporter.java @@ -0,0 +1,357 @@ +package com.coze.loop.trace; + +import com.coze.loop.entity.UploadFile; +import com.coze.loop.entity.UploadSpan; +import com.coze.loop.http.HttpClient; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Custom OpenTelemetry SpanExporter that exports spans to CozeLoop platform. + * + *

This class implements OpenTelemetry's {@link SpanExporter} interface and is responsible for: + *

    + *
  • Converting OpenTelemetry {@link SpanData} to CozeLoop {@link UploadSpan} format
  • + *
  • Extracting and uploading multimodal files (images, large text) via {@link FileUploader}
  • + *
  • Implementing second-level batching: splitting spans into batches of 25 for remote export
  • + *
  • Handling export errors gracefully: individual batch failures don't prevent other batches
  • + *
+ * + *

Two-Level Batching Architecture: + *

    + *
  1. First Level (OpenTelemetry BatchSpanProcessor): Receives spans from the application, + * batches them up to the configured batch size (default: 512), and sends them to this exporter
  2. + *
  3. Second Level (This Exporter): Further splits the received batch into smaller batches + * of 25 spans each, then exports each sub-batch to the CozeLoop platform
  4. + *
+ * + *

Why 25 Spans Per Batch? + * The batch size of 25 is optimized for: + *

    + *
  • Network efficiency: Smaller batches reduce payload size and improve reliability
  • + *
  • Error isolation: Failures in one batch don't affect other batches
  • + *
  • Server processing: CozeLoop platform processes smaller batches more efficiently
  • + *
+ * + *

Error Handling: + *

    + *
  • Individual batch failures are logged but don't stop processing of other batches
  • + *
  • Conversion errors (span data to UploadSpan) fail the entire export
  • + *
  • File upload errors are handled gracefully (span is still exported without file reference)
  • + *
  • Network errors are retried by the HTTP client (see {@link HttpClient})
  • + *
+ * + *

Multimodal Support: + * The exporter automatically: + *

    + *
  • Extracts file references from span attributes (images, large text)
  • + *
  • Uploads files to CozeLoop object storage via {@link FileUploader}
  • + *
  • Attaches object storage keys to spans for later retrieval
  • + *
+ * + *

Thread Safety: + * This exporter is thread-safe and can be called concurrently from multiple threads. + * The {@code isShutdown} flag is volatile to ensure proper visibility across threads. + * + *

Example Flow: + *

{@code
+ * // 1. OpenTelemetry BatchSpanProcessor sends 100 spans to export()
+ * // 2. Exporter converts all 100 spans to UploadSpan format
+ * // 3. Exporter splits into 4 batches of 25 spans each
+ * // 4. Each batch is exported independently to CozeLoop platform
+ * // 5. Results are logged and aggregated
+ * }
+ * + * @see SpanExporter + * @see SpanConverter + * @see FileUploader + * @see OpenTelemetry SpanExporter Specification + */ +public class CozeLoopSpanExporter implements SpanExporter { + private static final Logger logger = LoggerFactory.getLogger(CozeLoopSpanExporter.class); + + /** + * Batch size for exporting spans to remote server. + * + *

Each batch contains at most 25 spans. This is the second-level batch size + * (the first level is handled by OpenTelemetry's BatchSpanProcessor). + * + *

This value is chosen to balance: + *

    + *
  • Network efficiency (smaller payloads)
  • + *
  • Error isolation (failures don't affect too many spans)
  • + *
  • Server processing capacity
  • + *
+ */ + private static final int EXPORT_BATCH_SIZE = 25; + + private final HttpClient httpClient; + private final String spanEndpoint; + @SuppressWarnings("unused") // Used by FileUploader constructor + private final String fileEndpoint; + private final String workspaceId; + private final String serviceName; + private final FileUploader fileUploader; + + private volatile boolean isShutdown = false; + + /** + * Create a new CozeLoopSpanExporter. + * + *

This constructor initializes the exporter with the necessary components: + *

    + *
  • HTTP client for API calls
  • + *
  • Endpoints for span and file uploads
  • + *
  • Workspace and service identification
  • + *
  • File uploader for multimodal content
  • + *
+ * + * @param httpClient the HTTP client for making API calls (handles retries, auth, etc.) + * @param spanEndpoint the CozeLoop API endpoint for uploading spans + * @param fileEndpoint the CozeLoop API endpoint for uploading files (multimodal content) + * @param workspaceId the CozeLoop workspace ID + * @param serviceName the service name (used for resource identification) + */ + public CozeLoopSpanExporter(HttpClient httpClient, + String spanEndpoint, + String fileEndpoint, + String workspaceId, + String serviceName) { + this.httpClient = httpClient; + this.spanEndpoint = spanEndpoint; + this.fileEndpoint = fileEndpoint; + this.workspaceId = workspaceId; + this.serviceName = serviceName; + this.fileUploader = new FileUploader(httpClient, fileEndpoint, workspaceId); + } + + /** + * Export spans to CozeLoop platform. + * + *

This method is called by OpenTelemetry's BatchSpanProcessor with a batch of spans. + * The implementation: + *

    + *
  1. Converts all OpenTelemetry SpanData to CozeLoop UploadSpan format
  2. + *
  3. Extracts and uploads multimodal files (if any)
  4. + *
  5. Splits the batch into sub-batches of 25 spans
  6. + *
  7. Exports each sub-batch independently to the remote server
  8. + *
  9. Handles errors gracefully (one batch failure doesn't stop others)
  10. + *
+ * + *

Error Handling Strategy: + *

    + *
  • If conversion fails for any span, the entire export fails (returns failure)
  • + *
  • If a sub-batch export fails, other sub-batches continue processing
  • + *
  • If all sub-batches succeed, returns success
  • + *
  • If any sub-batch fails, returns failure (but all successful batches are still exported)
  • + *
+ * + *

Performance Considerations: + *

    + *
  • File extraction and upload happen synchronously (may block briefly)
  • + *
  • Batch splitting is O(n) where n is the number of spans
  • + *
  • Network calls are made sequentially (one batch at a time)
  • + *
+ * + * @param spans the collection of spans to export (from OpenTelemetry BatchSpanProcessor) + * @return CompletableResultCode indicating success or failure + */ + @Override + public CompletableResultCode export(@javax.annotation.Nonnull Collection spans) { + // Check if exporter is shutdown + if (isShutdown) { + logger.warn("Export called after shutdown, ignoring"); + return CompletableResultCode.ofFailure(); + } + + // Handle empty collections + if (spans == null || spans.isEmpty()) { + return CompletableResultCode.ofSuccess(); + } + + // Step 1: Convert all spans to UploadSpan format + // This conversion extracts all span data (attributes, events, timing, etc.) + // and transforms it into CozeLoop's UploadSpan format + List uploadSpans = new ArrayList<>(); + try { + for (SpanData spanData : spans) { + // Convert OpenTelemetry SpanData to CozeLoop UploadSpan + UploadSpan uploadSpan = SpanConverter.convert(spanData, workspaceId, serviceName); + + // Step 2: Handle multimodal content (images, large text) + // Extract file references from span attributes and upload them + List files = fileUploader.extractFiles(spanData); + if (!files.isEmpty()) { + // Upload files to CozeLoop object storage + String objectStorage = fileUploader.uploadFiles(files); + if (objectStorage != null) { + // Attach object storage key to span for later retrieval + uploadSpan.setObjectStorage(objectStorage); + } + } + + uploadSpans.add(uploadSpan); + } + } catch (Exception e) { + // Conversion errors fail the entire export + logger.error("Failed to convert spans for export", e); + return CompletableResultCode.ofFailure(); + } + + if (uploadSpans.isEmpty()) { + return CompletableResultCode.ofSuccess(); + } + + // Step 3: Split into batches of EXPORT_BATCH_SIZE (25 spans each) + int totalSpans = uploadSpans.size(); + int totalBatches = (totalSpans + EXPORT_BATCH_SIZE - 1) / EXPORT_BATCH_SIZE; + int successCount = 0; + int failureCount = 0; + + logger.debug("Exporting {} spans in {} batches (batch size: {})", + totalSpans, totalBatches, EXPORT_BATCH_SIZE); + + // Step 4: Export each batch independently + for (int i = 0; i < totalBatches; i++) { + int start = i * EXPORT_BATCH_SIZE; + int end = Math.min(start + EXPORT_BATCH_SIZE, totalSpans); + List batch = uploadSpans.subList(start, end); + + try { + // Export this batch to CozeLoop platform + exportBatch(batch, i + 1, totalBatches); + successCount++; + logger.debug("Successfully exported batch {}/{} ({} spans)", + i + 1, totalBatches, batch.size()); + } catch (Exception e) { + // Individual batch failures don't stop other batches + failureCount++; + logger.error("Failed to export batch {}/{} ({} spans): {}", + i + 1, totalBatches, batch.size(), e.getMessage(), e); + // Continue processing other batches even if one fails + } + } + + // Step 5: Return result based on batch outcomes + if (failureCount == 0) { + logger.debug("Successfully exported all {} spans in {} batches", + totalSpans, totalBatches); + return CompletableResultCode.ofSuccess(); + } else { + logger.warn("Exported {} spans: {} batches succeeded, {} batches failed", + totalSpans, successCount, failureCount); + // Return failure if any batch failed (but successful batches are still exported) + return CompletableResultCode.ofFailure(); + } + } + + /** + * Export a single batch of spans to the remote server. + * + *

This method sends a batch of UploadSpan objects to the CozeLoop platform + * via HTTP POST. The payload is a JSON object containing the spans array. + * + *

Payload Format: + *

{@code
+     * {
+     *   "spans": [
+     *     { ... },
+     *     { ... },
+     *     ...
+     *   ]
+     * }
+     * }
+ * + *

Error Handling: + * Exceptions thrown by this method are caught by the caller ({@link #export}), + * which logs the error and continues processing other batches. + * + *

Network Retries: + * The HTTP client handles retries automatically (see {@link HttpClient}). + * This method will only throw an exception if all retries are exhausted. + * + * @param batch the batch of upload spans to export (typically 25 spans) + * @param batchNumber the batch number (1-based, for logging purposes) + * @param totalBatches the total number of batches (for logging purposes) + * @throws Exception if the HTTP request fails after all retries + */ + private void exportBatch(List batch, int batchNumber, int totalBatches) throws Exception { + // Build payload: { "spans": [ ... ] } + Map payload = new HashMap<>(); + payload.put("spans", batch); + + // Send HTTP POST request to CozeLoop platform + // The HTTP client handles authentication, retries, and error handling + httpClient.post(spanEndpoint, payload); + + // Log at trace level for detailed debugging + if (logger.isTraceEnabled()) { + logger.trace("Exported batch {}/{} with {} spans to CozeLoop", + batchNumber, totalBatches, batch.size()); + } + } + + /** + * Flush any pending spans. + * + *

This exporter doesn't buffer spans internally (all buffering is handled + * by OpenTelemetry's BatchSpanProcessor), so this method is a no-op. + * + *

When called, it immediately returns success since there's nothing to flush. + * + * @return always returns success (no buffering to flush) + */ + @Override + public CompletableResultCode flush() { + // No buffering in this exporter, so flush is a no-op + // All buffering is handled by OpenTelemetry's BatchSpanProcessor + return CompletableResultCode.ofSuccess(); + } + + /** + * Shutdown the exporter and release resources. + * + *

This method should be called when the application is shutting down to: + *

    + *
  • Mark the exporter as shutdown (prevents new exports)
  • + *
  • Close HTTP client connections
  • + *
  • Release any held resources
  • + *
+ * + *

Important: After shutdown, the exporter cannot be used again. + * Any calls to {@link #export} after shutdown will return failure. + * + *

This method is idempotent: calling it multiple times is safe. + * + * @return CompletableResultCode indicating success or failure + */ + @Override + public CompletableResultCode shutdown() { + if (isShutdown) { + // Already shutdown, return success (idempotent) + return CompletableResultCode.ofSuccess(); + } + + isShutdown = true; + + try { + // Close HTTP client to release connections and resources + httpClient.close(); + logger.info("CozeLoopSpanExporter shutdown completed"); + return CompletableResultCode.ofSuccess(); + } catch (Exception e) { + logger.error("Failed to shutdown exporter", e); + return CompletableResultCode.ofFailure(); + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/trace/CozeLoopTracerProvider.java b/cozeloop-core/src/main/java/com/coze/loop/trace/CozeLoopTracerProvider.java new file mode 100644 index 0000000..8c46eea --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/trace/CozeLoopTracerProvider.java @@ -0,0 +1,372 @@ +package com.coze.loop.trace; + +import com.coze.loop.http.HttpClient; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.TracerProvider; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.semconv.ResourceAttributes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.TimeUnit; + +/** + * CozeLoop TracerProvider that wraps OpenTelemetry TracerProvider. + * + *

This class provides a bridge between CozeLoop SDK and OpenTelemetry, managing + * the complete trace lifecycle from span creation to export. It configures and + * initializes the OpenTelemetry SDK with CozeLoop-specific exporters and processors. + * + *

Architecture Overview: + *

    + *
  • Resource: Defines service metadata (service name, workspace ID)
  • + *
  • SdkTracerProvider: Manages Tracer instances and SpanProcessors
  • + *
  • BatchSpanProcessor: First-level batching (configurable batch size)
  • + *
  • CozeLoopSpanExporter: Second-level batching (25 spans per batch) and export
  • + *
+ * + *

Two-Level Batching Strategy: + *

    + *
  1. OpenTelemetry BatchSpanProcessor: Batches spans up to the configured + * batch size (default: 512) or until the schedule delay expires (default: 5000ms)
  2. + *
  3. CozeLoopSpanExporter: Further splits batches into groups of 25 spans + * for efficient remote server export
  4. + *
+ * + *

Context Propagation: + * The TracerProvider automatically handles OpenTelemetry context propagation, + * ensuring that trace context (trace ID, span ID, baggage) is automatically + * propagated to child spans within the same thread and across async boundaries. + * + *

Example Usage: + *

{@code
+ * TraceConfig config = TraceConfig.builder()
+ *     .maxQueueSize(2048)
+ *     .batchSize(512)
+ *     .scheduleDelayMillis(5000)
+ *     .exportTimeoutMillis(30000)
+ *     .build();
+ * 
+ * CozeLoopTracerProvider provider = CozeLoopTracerProvider.create(
+ *     httpClient, spanEndpoint, fileEndpoint, workspaceId, serviceName, config);
+ * 
+ * Tracer tracer = provider.getTracer("my-instrumentation");
+ * }
+ * + *

Shutdown: + * Always call {@link #shutdown()} when the application is shutting down to ensure + * all pending spans are flushed and exported: + *

{@code
+ * provider.shutdown(); // Flushes and exports all pending spans
+ * }
+ * + * @see OpenTelemetry Java Documentation + * @see CozeLoopSpanExporter + * @see BatchSpanProcessor + */ +public class CozeLoopTracerProvider { + private static final Logger logger = LoggerFactory.getLogger(CozeLoopTracerProvider.class); + + private final SdkTracerProvider sdkTracerProvider; + private final OpenTelemetrySdk openTelemetrySdk; + private final CozeLoopSpanExporter spanExporter; + + /** + * Private constructor. Use {@link #create} to create instances. + * + *

This constructor initializes the OpenTelemetry SDK with: + *

    + *
  1. CozeLoopSpanExporter: Custom exporter that converts and exports spans + * to CozeLoop platform in batches of 25
  2. + *
  3. Resource: Service metadata including service name and workspace ID
  4. + *
  5. BatchSpanProcessor: First-level batching processor with configurable + * queue size, batch size, and timing
  6. + *
  7. SdkTracerProvider: OpenTelemetry SDK tracer provider that manages + * the complete trace pipeline
  8. + *
+ * + *

The OpenTelemetry SDK is registered globally if not already initialized, + * allowing it to work seamlessly with other OpenTelemetry instrumentation. + * + * @param httpClient the HTTP client for API calls + * @param spanEndpoint the endpoint for uploading spans + * @param fileEndpoint the endpoint for uploading files (multimodal content) + * @param workspaceId the CozeLoop workspace ID + * @param serviceName the service name for resource identification + * @param config the trace configuration (batch sizes, timeouts, etc.) + */ + private CozeLoopTracerProvider(HttpClient httpClient, + String spanEndpoint, + String fileEndpoint, + String workspaceId, + String serviceName, + TraceConfig config) { + // Step 1: Create the custom span exporter + // This exporter implements OpenTelemetry's SpanExporter interface and handles + // conversion from OpenTelemetry SpanData to CozeLoop format, plus second-level batching + this.spanExporter = new CozeLoopSpanExporter( + httpClient, spanEndpoint, fileEndpoint, workspaceId, serviceName); + + // Step 2: Create Resource with service metadata + // Resource attributes are attached to all spans and help identify the service + // in the CozeLoop platform. These attributes are part of OpenTelemetry's + // resource model and are automatically included in all exported spans. + Resource resource = Resource.getDefault() + .merge(Resource.builder() + .put(ResourceAttributes.SERVICE_NAME, serviceName) + .put("workspace.id", workspaceId) + .build()); + + // Step 3: Create BatchSpanProcessor (first-level batching) + // This processor: + // - Queues spans up to maxQueueSize + // - Batches spans up to batchSize before sending to exporter + // - Exports on schedule (scheduleDelay) or when batch is full + // - Uses async processing to avoid blocking application threads + BatchSpanProcessor batchProcessor = BatchSpanProcessor.builder(spanExporter) + .setMaxQueueSize(config.getMaxQueueSize()) + .setMaxExportBatchSize(config.getBatchSize()) + .setScheduleDelay(config.getScheduleDelayMillis(), TimeUnit.MILLISECONDS) + .setExporterTimeout(config.getExportTimeoutMillis(), TimeUnit.MILLISECONDS) + .build(); + + // Step 4: Create SdkTracerProvider + // This is the core OpenTelemetry component that: + // - Manages Tracer instances + // - Processes spans through SpanProcessors + // - Attaches Resource attributes to all spans + this.sdkTracerProvider = SdkTracerProvider.builder() + .setResource(resource) + .addSpanProcessor(batchProcessor) + .build(); + + // Step 5: Build and register OpenTelemetry SDK + // We check if GlobalOpenTelemetry is already set to avoid conflicts in tests + // or when multiple OpenTelemetry instances are used in the same application. + OpenTelemetrySdk sdk; + try { + // Try to get existing instance - if this succeeds, it's already set + GlobalOpenTelemetry.get(); + // Already set, build without registering globally to avoid conflicts + sdk = OpenTelemetrySdk.builder() + .setTracerProvider(sdkTracerProvider) + .build(); + logger.debug("OpenTelemetry already initialized globally, using non-global instance"); + } catch (IllegalStateException e) { + // Not set yet, register globally (normal case) + // This makes the TracerProvider available via GlobalOpenTelemetry.get() + sdk = OpenTelemetrySdk.builder() + .setTracerProvider(sdkTracerProvider) + .buildAndRegisterGlobal(); + } + this.openTelemetrySdk = sdk; + + logger.info("CozeLoop TracerProvider initialized with service: {}, workspace: {}", + serviceName, workspaceId); + } + + /** + * Create a new CozeLoopTracerProvider. + * + * @param httpClient the HTTP client + * @param spanEndpoint the span upload endpoint + * @param fileEndpoint the file upload endpoint + * @param workspaceId the workspace ID + * @param serviceName the service name + * @param config the trace configuration + * @return CozeLoopTracerProvider instance + */ + public static CozeLoopTracerProvider create(HttpClient httpClient, + String spanEndpoint, + String fileEndpoint, + String workspaceId, + String serviceName, + TraceConfig config) { + return new CozeLoopTracerProvider( + httpClient, spanEndpoint, fileEndpoint, workspaceId, serviceName, config); + } + + /** + * Get a Tracer for creating spans. + * + *

The instrumentation name identifies the library or framework that is + * creating spans. This helps organize spans in the CozeLoop platform. + * + *

Example: + *

{@code
+     * Tracer tracer = provider.getTracer("my-application", "1.0.0");
+     * Span span = tracer.spanBuilder("operation").startSpan();
+     * }
+ * + * @param instrumentationName the name of the instrumentation library + * (e.g., "cozeloop-java-sdk", "my-application") + * @return Tracer instance for creating spans + */ + public Tracer getTracer(String instrumentationName) { + return openTelemetrySdk.getTracer(instrumentationName); + } + + /** + * Get the underlying OpenTelemetry TracerProvider. + * + *

This provides direct access to the OpenTelemetry TracerProvider for + * advanced use cases that require OpenTelemetry-specific APIs. + * + *

Example: + *

{@code
+     * TracerProvider provider = tracerProvider.getTracerProvider();
+     * // Use OpenTelemetry APIs directly
+     * }
+ * + * @return the underlying OpenTelemetry TracerProvider + */ + public TracerProvider getTracerProvider() { + return sdkTracerProvider; + } + + /** + * Shutdown the tracer provider and flush all pending spans. + * + *

This method should be called when the application is shutting down to ensure: + *

    + *
  • All pending spans are flushed from the queue
  • + *
  • All spans are exported to CozeLoop platform
  • + *
  • Resources are properly released
  • + *
+ * + *

Important: After shutdown, the TracerProvider cannot be used again. + * You must create a new instance if needed. + * + *

The shutdown process: + *

    + *
  1. Force flush all pending spans (waits up to 10 seconds)
  2. + *
  3. Shutdown the TracerProvider (waits up to 10 seconds)
  4. + *
  5. Shutdown the SpanExporter
  6. + *
+ * + *

Example: + *

{@code
+     * Runtime.getRuntime().addShutdownHook(new Thread(() -> {
+     *     tracerProvider.shutdown();
+     * }));
+     * }
+ */ + public void shutdown() { + logger.info("Shutting down CozeLoop TracerProvider"); + try { + // Force flush: ensures all queued spans are processed and exported + sdkTracerProvider.forceFlush().join(10, TimeUnit.SECONDS); + // Shutdown: stops accepting new spans and flushes remaining ones + sdkTracerProvider.shutdown().join(10, TimeUnit.SECONDS); + // Shutdown exporter: closes HTTP connections and releases resources + spanExporter.shutdown(); + } catch (Exception e) { + logger.error("Error shutting down tracer provider", e); + } + } + + /** + * Trace configuration for OpenTelemetry BatchSpanProcessor. + * + *

This configuration controls how spans are batched and exported: + *

    + *
  • maxQueueSize: Maximum number of spans that can be queued + * before spans are dropped (default: 2048)
  • + *
  • batchSize: Maximum number of spans per batch sent to exporter + * (default: 512). Note: CozeLoopSpanExporter further splits into batches of 25
  • + *
  • scheduleDelayMillis: Time between automatic batch exports + * (default: 5000ms = 5 seconds)
  • + *
  • exportTimeoutMillis: Maximum time to wait for export to complete + * (default: 30000ms = 30 seconds)
  • + *
+ * + *

Tuning Guidelines: + *

    + *
  • High Throughput: Increase maxQueueSize and batchSize
  • + *
  • Low Latency: Decrease scheduleDelayMillis
  • + *
  • Network Issues: Increase exportTimeoutMillis
  • + *
+ */ + public static class TraceConfig { + /** Maximum number of spans in the queue before dropping (default: 2048) */ + private int maxQueueSize = 2048; + + /** Maximum spans per batch sent to exporter (default: 512) */ + private int batchSize = 512; + + /** Delay between automatic batch exports in milliseconds (default: 5000) */ + private long scheduleDelayMillis = 5000; + + /** Timeout for export operations in milliseconds (default: 30000) */ + private long exportTimeoutMillis = 30000; + + public int getMaxQueueSize() { + return maxQueueSize; + } + + public void setMaxQueueSize(int maxQueueSize) { + this.maxQueueSize = maxQueueSize; + } + + public int getBatchSize() { + return batchSize; + } + + public void setBatchSize(int batchSize) { + this.batchSize = batchSize; + } + + public long getScheduleDelayMillis() { + return scheduleDelayMillis; + } + + public void setScheduleDelayMillis(long scheduleDelayMillis) { + this.scheduleDelayMillis = scheduleDelayMillis; + } + + public long getExportTimeoutMillis() { + return exportTimeoutMillis; + } + + public void setExportTimeoutMillis(long exportTimeoutMillis) { + this.exportTimeoutMillis = exportTimeoutMillis; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private final TraceConfig config = new TraceConfig(); + + public Builder maxQueueSize(int size) { + config.maxQueueSize = size; + return this; + } + + public Builder batchSize(int size) { + config.batchSize = size; + return this; + } + + public Builder scheduleDelayMillis(long millis) { + config.scheduleDelayMillis = millis; + return this; + } + + public Builder exportTimeoutMillis(long millis) { + config.exportTimeoutMillis = millis; + return this; + } + + public TraceConfig build() { + return config; + } + } + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/trace/FileUploader.java b/cozeloop-core/src/main/java/com/coze/loop/trace/FileUploader.java new file mode 100644 index 0000000..98109f6 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/trace/FileUploader.java @@ -0,0 +1,130 @@ +package com.coze.loop.trace; + +import com.coze.loop.entity.UploadFile; +import com.coze.loop.http.HttpClient; +import com.coze.loop.internal.IdGenerator; +import com.coze.loop.internal.JsonUtils; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.sdk.trace.data.SpanData; +import okhttp3.MultipartBody; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Uploader for multimodal files extracted from span data. + */ +public class FileUploader { + private static final Logger logger = LoggerFactory.getLogger(FileUploader.class); + private static final Pattern BASE64_PATTERN = Pattern.compile( + "data:image/([a-z]+);base64,([A-Za-z0-9+/=]+)", Pattern.CASE_INSENSITIVE); + + private final HttpClient httpClient; + private final String uploadEndpoint; + private final String workspaceId; + + public FileUploader(HttpClient httpClient, String uploadEndpoint, String workspaceId) { + this.httpClient = httpClient; + this.uploadEndpoint = uploadEndpoint; + this.workspaceId = workspaceId; + } + + /** + * Extract multimodal files from span data. + * + * @param spanData the span data + * @return list of upload files + */ + public List extractFiles(SpanData spanData) { + List files = new ArrayList<>(); + + // Check input for base64 images + String input = spanData.getAttributes().get(AttributeKey.stringKey("cozeloop.input")); + if (input != null) { + extractBase64Files(input, "input", files); + } + + // Check output for base64 images + String output = spanData.getAttributes().get(AttributeKey.stringKey("cozeloop.output")); + if (output != null) { + extractBase64Files(output, "output", files); + } + + return files; + } + + /** + * Extract base64 encoded files from content. + */ + private void extractBase64Files(String content, String tagKey, List files) { + Matcher matcher = BASE64_PATTERN.matcher(content); + + while (matcher.find()) { + String fileType = matcher.group(1); + String base64Data = matcher.group(2); + + UploadFile file = UploadFile.builder() + .tosKey(generateTosKey()) + .data(base64Data) + .uploadType("base64") + .tagKey(tagKey) + .fileType(fileType) + .name("image." + fileType) + .spaceId(workspaceId) + .build(); + + files.add(file); + } + } + + /** + * Upload files to the server. + * + * @param files list of files to upload + * @return object storage key + */ + public String uploadFiles(List files) { + if (files.isEmpty()) { + return null; + } + + try { + // Build multipart form data + MultipartBody.Builder builder = new MultipartBody.Builder() + .setType(MultipartBody.FORM); + + // Add files as JSON + String filesJson = JsonUtils.toJson(files); + builder.addFormDataPart("files", filesJson); + + MultipartBody formData = builder.build(); + + // Upload files + String response = httpClient.postMultipart(uploadEndpoint, formData); + + // Parse response to get object storage key + // Assuming response contains {"object_storage": "key"} + @SuppressWarnings("unchecked") + java.util.Map responseMap = JsonUtils.fromJson( + response, java.util.Map.class); + + return (String) responseMap.get("object_storage"); + } catch (Exception e) { + logger.error("Failed to upload files", e); + return null; + } + } + + /** + * Generate a unique TOS (Object Storage) key. + */ + private String generateTosKey() { + return "cozeloop/" + workspaceId + "/" + IdGenerator.generateUuid(); + } +} + diff --git a/cozeloop-core/src/main/java/com/coze/loop/trace/SpanConverter.java b/cozeloop-core/src/main/java/com/coze/loop/trace/SpanConverter.java new file mode 100644 index 0000000..fe75c62 --- /dev/null +++ b/cozeloop-core/src/main/java/com/coze/loop/trace/SpanConverter.java @@ -0,0 +1,292 @@ +package com.coze.loop.trace; + +import com.coze.loop.entity.UploadSpan; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.sdk.trace.data.SpanData; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Converter to transform OpenTelemetry SpanData to CozeLoop UploadSpan format. + * + *

This utility class handles the conversion between OpenTelemetry's span representation + * ({@link SpanData}) and CozeLoop's span format ({@link UploadSpan}). It extracts and + * transforms all span data including: + *

    + *
  • Basic span information (trace ID, span ID, parent ID, name)
  • + *
  • Timing information (start time, duration) - converted from nanoseconds to microseconds
  • + *
  • Status information (status code, error state)
  • + *
  • Attributes (tags) - categorized by type (string, long, double, boolean)
  • + *
  • System tags - special tags prefixed with "system."
  • + *
  • CozeLoop-specific attributes (input, output, span type, object storage)
  • + *
+ * + *

Attribute Mapping Rules: + *

    + *
  • CozeLoop attributes (prefix "cozeloop."): Extracted to specific UploadSpan fields + *
      + *
    • "cozeloop.input" → {@link UploadSpan#setInput(String)}
    • + *
    • "cozeloop.output" → {@link UploadSpan#setOutput(String)}
    • + *
    • "cozeloop.object_storage" → {@link UploadSpan#setObjectStorage(String)}
    • + *
    • "span.type" → {@link UploadSpan#setSpanType(String)}
    • + *
    + *
  • + *
  • System tags (prefix "system."): Extracted to system tag maps + *
      + *
    • system.* → systemTagsString, systemTagsLong, systemTagsDouble
    • + *
    + *
  • + *
  • Regular attributes: Extracted to tag maps by type + *
      + *
    • String → tagsString
    • + *
    • Long/Integer → tagsLong
    • + *
    • Double → tagsDouble
    • + *
    • Boolean → tagsBool
    • + *
    + *
  • + *
+ * + *

Status Code Conversion: + *

    + *
  • OpenTelemetry OK → CozeLoop 0
  • + *
  • OpenTelemetry ERROR → CozeLoop 1
  • + *
  • OpenTelemetry UNSET → CozeLoop 2
  • + *
+ * + *

Timing Conversion: + * OpenTelemetry uses nanoseconds for timestamps, while CozeLoop uses microseconds. + * The converter automatically converts: + *

    + *
  • Start time: nanoseconds → microseconds
  • + *
  • Duration: calculated as (end - start) in microseconds
  • + *
+ * + *

Thread Safety: + * This class is thread-safe. All methods are static and stateless. + * + *

Usage: + *

{@code
+ * SpanData spanData = ...; // from OpenTelemetry
+ * UploadSpan uploadSpan = SpanConverter.convert(spanData, workspaceId, serviceName);
+ * // uploadSpan is ready to be sent to CozeLoop platform
+ * }
+ * + * @see SpanData + * @see UploadSpan + * @see CozeLoopSpanExporter + */ +public final class SpanConverter { + + private SpanConverter() { + // Utility class + } + + /** + * Convert OpenTelemetry SpanData to CozeLoop UploadSpan. + * + *

This method performs a complete conversion of OpenTelemetry span data to CozeLoop format. + * The conversion process: + *

    + *
  1. Extracts basic span information (IDs, names, timing)
  2. + *
  3. Converts timing from nanoseconds to microseconds
  4. + *
  5. Extracts and categorizes attributes by type
  6. + *
  7. Maps CozeLoop-specific attributes to dedicated fields
  8. + *
  9. Separates system tags from regular tags
  10. + *
+ * + *

Attribute Processing: + * Attributes are processed in the following order: + *

    + *
  1. CozeLoop-specific attributes are extracted first (cozeloop.*)
  2. + *
  3. System tags are identified by "system." prefix
  4. + *
  5. Regular attributes are categorized by type (String, Long, Double, Boolean)
  6. + *
  7. Integer values are converted to Long
  8. + *
+ * + *

Null Handling: + *

    + *
  • Null attributes are skipped
  • + *
  • Empty tag maps are not set on UploadSpan (to reduce payload size)
  • + *
  • Null span type defaults to "custom"
  • + *
+ * + * @param spanData the OpenTelemetry span data to convert + * @param workspaceId the CozeLoop workspace ID (added to UploadSpan) + * @param serviceName the service name (added to UploadSpan) + * @return converted UploadSpan ready for export to CozeLoop platform + * @throws NullPointerException if spanData is null + */ + public static UploadSpan convert(SpanData spanData, String workspaceId, String serviceName) { + UploadSpan uploadSpan = new UploadSpan(); + + // Step 1: Extract basic span information + // These fields directly map from OpenTelemetry to CozeLoop format + uploadSpan.setTraceId(spanData.getTraceId()); + uploadSpan.setSpanId(spanData.getSpanId()); + uploadSpan.setParentId(spanData.getParentSpanId()); + uploadSpan.setLogId(spanData.getSpanId()); // Use span ID as log ID + uploadSpan.setWorkspaceId(workspaceId); + uploadSpan.setServiceName(serviceName); + + // Step 2: Convert timing information + // OpenTelemetry uses nanoseconds (epochNanos), CozeLoop uses microseconds + // Conversion: 1 microsecond = 1000 nanoseconds + long startMicros = TimeUnit.NANOSECONDS.toMicros(spanData.getStartEpochNanos()); + long endMicros = TimeUnit.NANOSECONDS.toMicros(spanData.getEndEpochNanos()); + uploadSpan.setStartedAtMicros(startMicros); + uploadSpan.setDurationMicros(endMicros - startMicros); + + // Step 3: Extract span name and type + uploadSpan.setSpanName(spanData.getName()); + + // Step 4: Get all attributes from the span + Attributes attributes = spanData.getAttributes(); + + // Step 5: Extract span type (defaults to "custom" if not specified) + String spanType = attributes.get(AttributeKey.stringKey("span.type")); + uploadSpan.setSpanType(spanType != null ? spanType : "custom"); + + // Step 6: Convert status code + // OpenTelemetry: OK, ERROR, UNSET + // CozeLoop: 0 (OK), 1 (ERROR), 2 (UNSET) + int statusCode = convertStatusCode(spanData.getStatus().getStatusCode()); + uploadSpan.setStatusCode(statusCode); + + // Step 7: Extract CozeLoop-specific attributes + // These are special attributes that map to dedicated UploadSpan fields + String input = attributes.get(AttributeKey.stringKey("cozeloop.input")); + String output = attributes.get(AttributeKey.stringKey("cozeloop.output")); + uploadSpan.setInput(input); + uploadSpan.setOutput(output); + + // Step 8: Extract object storage key (for multimodal content) + // This is set by FileUploader when files are uploaded + String objectStorage = attributes.get(AttributeKey.stringKey("cozeloop.object_storage")); + uploadSpan.setObjectStorage(objectStorage); + + // Step 9: Extract and categorize attributes by type + // Attributes are separated into: + // - Regular tags (by type: String, Long, Double, Boolean) + // - System tags (prefixed with "system.") + // - CozeLoop attributes are already extracted above + Map tagsString = new HashMap<>(); + Map tagsLong = new HashMap<>(); + Map tagsDouble = new HashMap<>(); + Map tagsBool = new HashMap<>(); + Map systemTagsString = new HashMap<>(); + Map systemTagsLong = new HashMap<>(); + Map systemTagsDouble = new HashMap<>(); + + // Iterate through all attributes and categorize them + attributes.forEach((key, value) -> { + String keyStr = key.getKey(); + + // Skip CozeLoop-specific attributes (already extracted above) + if (keyStr.startsWith("cozeloop.")) { + return; + } + + // Check if this is a system tag (prefixed with "system.") + boolean isSystemTag = keyStr.startsWith("system."); + + // Categorize by value type + if (value instanceof String) { + if (isSystemTag) { + systemTagsString.put(keyStr, (String) value); + } else { + tagsString.put(keyStr, (String) value); + } + } else if (value instanceof Long) { + if (isSystemTag) { + systemTagsLong.put(keyStr, (Long) value); + } else { + tagsLong.put(keyStr, (Long) value); + } + } else if (value instanceof Double) { + if (isSystemTag) { + systemTagsDouble.put(keyStr, (Double) value); + } else { + tagsDouble.put(keyStr, (Double) value); + } + } else if (value instanceof Boolean) { + // Boolean values are only stored as regular tags (not system tags) + if (!isSystemTag) { + tagsBool.put(keyStr, (Boolean) value); + } + } else if (value instanceof Integer) { + // Convert Integer to Long for consistency + long longValue = ((Integer) value).longValue(); + if (isSystemTag) { + systemTagsLong.put(keyStr, longValue); + } else { + tagsLong.put(keyStr, longValue); + } + } + // Note: Other types (Float, etc.) are not currently supported + // They would need to be converted to supported types + }); + + // Step 10: Set tag maps on UploadSpan (only if non-empty to reduce payload size) + + if (!tagsString.isEmpty()) { + uploadSpan.setTagsString(tagsString); + } + if (!tagsLong.isEmpty()) { + uploadSpan.setTagsLong(tagsLong); + } + if (!tagsDouble.isEmpty()) { + uploadSpan.setTagsDouble(tagsDouble); + } + if (!tagsBool.isEmpty()) { + uploadSpan.setTagsBool(tagsBool); + } + if (!systemTagsString.isEmpty()) { + uploadSpan.setSystemTagsString(systemTagsString); + } + if (!systemTagsLong.isEmpty()) { + uploadSpan.setSystemTagsLong(systemTagsLong); + } + if (!systemTagsDouble.isEmpty()) { + uploadSpan.setSystemTagsDouble(systemTagsDouble); + } + + return uploadSpan; + } + + /** + * Convert OpenTelemetry StatusCode to CozeLoop status code. + * + *

OpenTelemetry uses enum values (OK, ERROR, UNSET), while CozeLoop uses + * integer codes. This method performs the mapping: + *

    + *
  • OK → 0 (success)
  • + *
  • ERROR → 1 (error occurred)
  • + *
  • UNSET → 2 (status not set)
  • + *
+ * + *

The status code indicates the outcome of the span: + *

    + *
  • 0 (OK): Span completed successfully
  • + *
  • 1 (ERROR): Span ended with an error
  • + *
  • 2 (UNSET): Status was not explicitly set
  • + *
+ * + * @param statusCode the OpenTelemetry StatusCode to convert + * @return the corresponding CozeLoop status code (0, 1, or 2) + */ + private static int convertStatusCode(StatusCode statusCode) { + switch (statusCode) { + case OK: + return 0; // Success + case ERROR: + return 1; // Error occurred + default: + return 2; // UNSET - status not explicitly set + } + } +} + diff --git a/cozeloop-core/src/test/java/com/coze/loop/auth/TokenAuthTest.java b/cozeloop-core/src/test/java/com/coze/loop/auth/TokenAuthTest.java new file mode 100644 index 0000000..ba2be45 --- /dev/null +++ b/cozeloop-core/src/test/java/com/coze/loop/auth/TokenAuthTest.java @@ -0,0 +1,64 @@ +package com.coze.loop.auth; + +import com.coze.loop.exception.CozeLoopException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for TokenAuth. + */ +class TokenAuthTest { + + @Test + void testConstructorWithValidToken() { + TokenAuth auth = new TokenAuth("test-token"); + + assertThat(auth.getToken()).isEqualTo("test-token"); + assertThat(auth.getType()).isEqualTo("Bearer"); + } + + @Test + void testConstructorWithNullToken() { + assertThatThrownBy(() -> new TokenAuth(null)) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(com.coze.loop.exception.ErrorCode.INVALID_PARAM); + }); + } + + @Test + void testConstructorWithEmptyToken() { + assertThatThrownBy(() -> new TokenAuth("")) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(com.coze.loop.exception.ErrorCode.INVALID_PARAM); + }); + } + + @Test + void testConstructorWithWhitespaceToken() { + assertThatThrownBy(() -> new TokenAuth(" ")) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(com.coze.loop.exception.ErrorCode.INVALID_PARAM); + }); + } + + @Test + void testGetToken() { + TokenAuth auth = new TokenAuth("my-token-123"); + assertThat(auth.getToken()).isEqualTo("my-token-123"); + } + + @Test + void testGetType() { + TokenAuth auth = new TokenAuth("token"); + assertThat(auth.getType()).isEqualTo("Bearer"); + } +} + diff --git a/cozeloop-core/src/test/java/com/coze/loop/client/CozeLoopClientImplTest.java b/cozeloop-core/src/test/java/com/coze/loop/client/CozeLoopClientImplTest.java new file mode 100644 index 0000000..40cb3a5 --- /dev/null +++ b/cozeloop-core/src/test/java/com/coze/loop/client/CozeLoopClientImplTest.java @@ -0,0 +1,238 @@ +package com.coze.loop.client; + +import com.coze.loop.entity.Message; +import com.coze.loop.entity.Prompt; +import com.coze.loop.exception.CozeLoopException; +import com.coze.loop.exception.ErrorCode; +import com.coze.loop.http.HttpClient; +import com.coze.loop.prompt.GetPromptParam; +import com.coze.loop.prompt.PromptProvider; +import com.coze.loop.trace.CozeLoopSpan; +import com.coze.loop.trace.CozeLoopTracerProvider; +import io.opentelemetry.api.trace.Tracer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * Unit tests for CozeLoopClientImpl. + */ +@ExtendWith(MockitoExtension.class) +class CozeLoopClientImplTest { + + @Mock + private CozeLoopTracerProvider tracerProvider; + + @Mock + private PromptProvider promptProvider; + + @Mock + private HttpClient httpClient; + + @Mock + private Tracer tracer; + + private CozeLoopClientImpl client; + private String workspaceId = "test-workspace"; + + @BeforeEach + void setUp() { + when(tracerProvider.getTracer(any())).thenReturn(tracer); + client = new CozeLoopClientImpl(workspaceId, tracerProvider, promptProvider, httpClient); + } + + @Test + void testGetWorkspaceId() { + assertThat(client.getWorkspaceId()).isEqualTo(workspaceId); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void testStartSpan() { + io.opentelemetry.api.trace.Span span = mock(io.opentelemetry.api.trace.Span.class); + io.opentelemetry.api.trace.SpanBuilder spanBuilder = mock(io.opentelemetry.api.trace.SpanBuilder.class); + io.opentelemetry.context.Scope scope = mock(io.opentelemetry.context.Scope.class); + + when(tracer.spanBuilder(any())).thenReturn(spanBuilder); + when(spanBuilder.setAttribute(any(io.opentelemetry.api.common.AttributeKey.class), any())).thenReturn(spanBuilder); + when(spanBuilder.startSpan()).thenReturn(span); + when(span.makeCurrent()).thenReturn(scope); + + CozeLoopSpan result = client.startSpan("test-span"); + + assertThat(result).isNotNull(); + verify(tracer).spanBuilder("test-span"); + } + + @Test + @SuppressWarnings({"unchecked", "rawtypes"}) + void testStartSpanWithType() { + io.opentelemetry.api.trace.Span span = mock(io.opentelemetry.api.trace.Span.class); + io.opentelemetry.api.trace.SpanBuilder spanBuilder = mock(io.opentelemetry.api.trace.SpanBuilder.class); + io.opentelemetry.context.Scope scope = mock(io.opentelemetry.context.Scope.class); + + when(tracer.spanBuilder(any())).thenReturn(spanBuilder); + when(spanBuilder.setAttribute(any(io.opentelemetry.api.common.AttributeKey.class), any())).thenReturn(spanBuilder); + when(spanBuilder.startSpan()).thenReturn(span); + when(span.makeCurrent()).thenReturn(scope); + + CozeLoopSpan result = client.startSpan("test-span", "llm"); + + assertThat(result).isNotNull(); + verify(spanBuilder).setAttribute(any(io.opentelemetry.api.common.AttributeKey.class), eq("llm")); + } + + @Test + void testGetTracer() { + Tracer result = client.getTracer(); + assertThat(result).isEqualTo(tracer); + } + + @Test + void testGetPrompt() { + GetPromptParam param = GetPromptParam.builder() + .promptKey("test-key") + .build(); + Prompt prompt = new Prompt(); + prompt.setPromptKey("test-key"); + + when(promptProvider.getPrompt(param)).thenReturn(prompt); + + Prompt result = client.getPrompt(param); + + assertThat(result).isNotNull(); + assertThat(result.getPromptKey()).isEqualTo("test-key"); + verify(promptProvider).getPrompt(param); + } + + @Test + void testFormatPrompt() { + Prompt prompt = new Prompt(); + Map variables = new HashMap<>(); + variables.put("name", "test"); + + List messages = new ArrayList<>(); + Message message = new Message(); + message.setContent("Hello test"); + messages.add(message); + + when(promptProvider.formatPrompt(prompt, variables)).thenReturn(messages); + + List result = client.formatPrompt(prompt, variables); + + assertThat(result).isNotNull(); + assertThat(result).hasSize(1); + verify(promptProvider).formatPrompt(prompt, variables); + } + + @Test + void testGetAndFormatPrompt() { + GetPromptParam param = GetPromptParam.builder() + .promptKey("test-key") + .build(); + Map variables = new HashMap<>(); + variables.put("name", "test"); + + List messages = new ArrayList<>(); + Message message = new Message(); + message.setContent("Hello test"); + messages.add(message); + + when(promptProvider.getAndFormat(param, variables)).thenReturn(messages); + + List result = client.getAndFormatPrompt(param, variables); + + assertThat(result).isNotNull(); + assertThat(result).hasSize(1); + verify(promptProvider).getAndFormat(param, variables); + } + + @Test + void testInvalidatePromptCache() { + GetPromptParam param = GetPromptParam.builder() + .promptKey("test-key") + .build(); + + client.invalidatePromptCache(param); + + verify(promptProvider).invalidateCache(param); + } + + @Test + void testShutdown() { + client.shutdown(); + + verify(tracerProvider).shutdown(); + verify(httpClient).close(); + } + + @Test + void testClose() { + client.close(); + + verify(tracerProvider).shutdown(); + verify(httpClient).close(); + } + + @Test + void testShutdownMultipleTimes() { + client.shutdown(); + client.shutdown(); + + // Should only shutdown once + verify(tracerProvider, times(1)).shutdown(); + verify(httpClient, times(1)).close(); + } + + @Test + void testOperationsAfterShutdown() { + client.shutdown(); + + assertThatThrownBy(() -> client.getWorkspaceId()) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.CLIENT_CLOSED); + }); + } + + @Test + void testStartSpanAfterShutdown() { + client.shutdown(); + + assertThatThrownBy(() -> client.startSpan("test")) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.CLIENT_CLOSED); + }); + } + + @Test + void testGetPromptAfterShutdown() { + client.shutdown(); + GetPromptParam param = GetPromptParam.builder() + .promptKey("test-key") + .build(); + + assertThatThrownBy(() -> client.getPrompt(param)) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.CLIENT_CLOSED); + }); + } +} + diff --git a/cozeloop-core/src/test/java/com/coze/loop/http/AuthInterceptorTest.java b/cozeloop-core/src/test/java/com/coze/loop/http/AuthInterceptorTest.java new file mode 100644 index 0000000..713504d --- /dev/null +++ b/cozeloop-core/src/test/java/com/coze/loop/http/AuthInterceptorTest.java @@ -0,0 +1,87 @@ +package com.coze.loop.http; + +import com.coze.loop.auth.Auth; +import okhttp3.Request; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * Unit tests for AuthInterceptor. + */ +class AuthInterceptorTest { + + private MockWebServer mockWebServer; + + @Mock + private Auth auth; + + @BeforeEach + void setUp() throws IOException { + MockitoAnnotations.openMocks(this); + mockWebServer = new MockWebServer(); + mockWebServer.start(); + } + + @AfterEach + void tearDown() throws IOException { + if (mockWebServer != null) { + mockWebServer.shutdown(); + } + } + + @Test + void testInterceptAddsAuthorizationHeader() throws IOException { + when(auth.getToken()).thenReturn("test-token-123"); + when(auth.getType()).thenReturn("Bearer"); + + mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody("OK")); + + AuthInterceptor interceptor = new AuthInterceptor(auth); + okhttp3.OkHttpClient client = new okhttp3.OkHttpClient.Builder() + .addInterceptor(interceptor) + .build(); + + Request request = new Request.Builder() + .url(mockWebServer.url("/test")) + .get() + .build(); + + okhttp3.Response response = client.newCall(request).execute(); + + assertThat(response.isSuccessful()).isTrue(); + assertThat(response.code()).isEqualTo(200); + } + + @Test + void testInterceptAddsUserAgentHeader() throws IOException { + when(auth.getToken()).thenReturn("token"); + when(auth.getType()).thenReturn("Bearer"); + + mockWebServer.enqueue(new MockResponse().setResponseCode(200)); + + AuthInterceptor interceptor = new AuthInterceptor(auth); + okhttp3.OkHttpClient client = new okhttp3.OkHttpClient.Builder() + .addInterceptor(interceptor) + .build(); + + Request request = new Request.Builder() + .url(mockWebServer.url("/test")) + .get() + .build(); + + okhttp3.Response response = client.newCall(request).execute(); + + assertThat(response.isSuccessful()).isTrue(); + } +} + diff --git a/cozeloop-core/src/test/java/com/coze/loop/http/HttpClientTest.java b/cozeloop-core/src/test/java/com/coze/loop/http/HttpClientTest.java new file mode 100644 index 0000000..5217a8f --- /dev/null +++ b/cozeloop-core/src/test/java/com/coze/loop/http/HttpClientTest.java @@ -0,0 +1,157 @@ +package com.coze.loop.http; + +import com.coze.loop.auth.Auth; +import com.coze.loop.exception.CozeLoopException; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +/** + * Unit tests for HttpClient. + */ +class HttpClientTest { + + private MockWebServer mockWebServer; + + @Mock + private Auth auth; + + @BeforeEach + void setUp() throws IOException { + MockitoAnnotations.openMocks(this); + mockWebServer = new MockWebServer(); + mockWebServer.start(); + + when(auth.getToken()).thenReturn("test-token"); + when(auth.getType()).thenReturn("Bearer"); + } + + @AfterEach + void tearDown() throws IOException { + if (mockWebServer != null) { + mockWebServer.shutdown(); + } + } + + @Test + void testGetRequest() throws IOException { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"status\":\"ok\"}")); + + HttpClient client = new HttpClient(auth); + String response = client.get(mockWebServer.url("/test").toString()); + + assertThat(response).isNotNull(); + assertThat(response).contains("status"); + assertThat(response).contains("ok"); + + client.close(); + } + + @Test + void testPostRequest() throws IOException { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"result\":\"success\"}")); + + HttpClient client = new HttpClient(auth); + Map body = new HashMap<>(); + body.put("key", "value"); + + String response = client.post(mockWebServer.url("/test").toString(), body); + + assertThat(response).isNotNull(); + assertThat(response).contains("result"); + + client.close(); + } + + @Test + void testPostRequestWithErrorResponse() throws IOException { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(404) + .setBody("Not Found")); + + HttpClient client = new HttpClient(auth); + + assertThatThrownBy(() -> client.post(mockWebServer.url("/test").toString(), new HashMap<>())) + .isInstanceOf(CozeLoopException.class); + + client.close(); + } + + @Test + void testGetRequestWithErrorResponse() throws IOException { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(500) + .setBody("Internal Server Error")); + + HttpClient client = new HttpClient(auth); + + assertThatThrownBy(() -> client.get(mockWebServer.url("/test").toString())) + .isInstanceOf(CozeLoopException.class); + + client.close(); + } + + @Test + void testPostMultipartRequest() throws IOException { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("Uploaded")); + + HttpClient client = new HttpClient(auth); + okhttp3.MultipartBody formData = new okhttp3.MultipartBody.Builder() + .setType(okhttp3.MultipartBody.FORM) + .addFormDataPart("file", "test.txt", + okhttp3.RequestBody.create("content", okhttp3.MediaType.parse("text/plain"))) + .build(); + + String response = client.postMultipart(mockWebServer.url("/upload").toString(), formData); + + assertThat(response).isEqualTo("Uploaded"); + + client.close(); + } + + @Test + void testClose() throws IOException { + HttpClient client = new HttpClient(auth); + + // Should not throw + client.close(); + } + + @Test + void testHttpClientWithCustomConfig() throws IOException { + HttpConfig config = HttpConfig.builder() + .connectTimeoutSeconds(10) + .readTimeoutSeconds(20) + .build(); + + HttpClient client = new HttpClient(auth, config); + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("OK")); + + String response = client.get(mockWebServer.url("/test").toString()); + assertThat(response).isEqualTo("OK"); + + client.close(); + } +} + diff --git a/cozeloop-core/src/test/java/com/coze/loop/internal/IdGeneratorTest.java b/cozeloop-core/src/test/java/com/coze/loop/internal/IdGeneratorTest.java new file mode 100644 index 0000000..87bc4d3 --- /dev/null +++ b/cozeloop-core/src/test/java/com/coze/loop/internal/IdGeneratorTest.java @@ -0,0 +1,91 @@ +package com.coze.loop.internal; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.RepeatedTest; + +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for IdGenerator. + */ +class IdGeneratorTest { + + private static final Pattern HEX_PATTERN = Pattern.compile("^[0-9a-f]+$"); + + @Test + void testGenerateTraceId() { + String traceId = IdGenerator.generateTraceId(); + + assertThat(traceId).isNotNull(); + assertThat(traceId).hasSize(32); + assertThat(traceId).matches(HEX_PATTERN); + } + + @Test + void testGenerateSpanId() { + String spanId = IdGenerator.generateSpanId(); + + assertThat(spanId).isNotNull(); + assertThat(spanId).hasSize(16); + assertThat(spanId).matches(HEX_PATTERN); + } + + @Test + void testGenerateHexString() { + String hex = IdGenerator.generateHexString(8); + + assertThat(hex).isNotNull(); + assertThat(hex).hasSize(8); + assertThat(hex).matches(HEX_PATTERN); + } + + @Test + void testGenerateHexStringWithDifferentLengths() { + assertThat(IdGenerator.generateHexString(1)).hasSize(1); + assertThat(IdGenerator.generateHexString(10)).hasSize(10); + assertThat(IdGenerator.generateHexString(64)).hasSize(64); + } + + @Test + void testGenerateUuid() { + String uuid = IdGenerator.generateUuid(); + + assertThat(uuid).isNotNull(); + assertThat(uuid).matches("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); + } + + @RepeatedTest(10) + void testTraceIdUniqueness() { + Set ids = new HashSet<>(); + for (int i = 0; i < 100; i++) { + String id = IdGenerator.generateTraceId(); + assertThat(ids).doesNotContain(id); + ids.add(id); + } + } + + @RepeatedTest(10) + void testSpanIdUniqueness() { + Set ids = new HashSet<>(); + for (int i = 0; i < 100; i++) { + String id = IdGenerator.generateSpanId(); + assertThat(ids).doesNotContain(id); + ids.add(id); + } + } + + @RepeatedTest(10) + void testUuidUniqueness() { + Set ids = new HashSet<>(); + for (int i = 0; i < 100; i++) { + String id = IdGenerator.generateUuid(); + assertThat(ids).doesNotContain(id); + ids.add(id); + } + } +} + diff --git a/cozeloop-core/src/test/java/com/coze/loop/internal/JsonUtilsTest.java b/cozeloop-core/src/test/java/com/coze/loop/internal/JsonUtilsTest.java new file mode 100644 index 0000000..60a6665 --- /dev/null +++ b/cozeloop-core/src/test/java/com/coze/loop/internal/JsonUtilsTest.java @@ -0,0 +1,106 @@ +package com.coze.loop.internal; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for JsonUtils. + */ +class JsonUtilsTest { + + @Test + void testToJsonWithObject() { + Map map = new HashMap<>(); + map.put("key1", "value1"); + map.put("key2", 123); + + String json = JsonUtils.toJson(map); + + assertThat(json).isNotNull(); + assertThat(json).contains("key1"); + assertThat(json).contains("value1"); + assertThat(json).contains("key2"); + assertThat(json).contains("123"); + } + + @Test + void testToJsonWithNull() { + String json = JsonUtils.toJson(null); + assertThat(json).isNull(); + } + + @Test + void testToJsonWithString() { + String input = "test string"; + String json = JsonUtils.toJson(input); + assertThat(json).isEqualTo(input); + } + + @Test + @SuppressWarnings("unchecked") + void testFromJsonWithMap() { + String json = "{\"key1\":\"value1\",\"key2\":123}"; + Map result = JsonUtils.fromJson(json, Map.class); + + assertThat(result).isNotNull(); + assertThat(result.get("key1")).isEqualTo("value1"); + assertThat(result.get("key2")).isEqualTo(123); + } + + @Test + void testFromJsonWithNull() { + String result = JsonUtils.fromJson(null, String.class); + assertThat(result).isNull(); + } + + @Test + void testFromJsonWithEmptyString() { + String result = JsonUtils.fromJson("", String.class); + assertThat(result).isNull(); + } + + @Test + void testFromJsonWithTypeReference() { + String json = "{\"key1\":\"value1\",\"key2\":\"value2\"}"; + Map result = JsonUtils.fromJson(json, new TypeReference>() {}); + + assertThat(result).isNotNull(); + assertThat(result.get("key1")).isEqualTo("value1"); + assertThat(result.get("key2")).isEqualTo("value2"); + } + + @Test + void testFromJsonWithInvalidJson() { + String invalidJson = "{invalid json}"; + + assertThatThrownBy(() -> JsonUtils.fromJson(invalidJson, Map.class)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to parse JSON"); + } + + @Test + void testGetMapper() { + assertThat(JsonUtils.getMapper()).isNotNull(); + } + + @Test + @SuppressWarnings("unchecked") + void testRoundTrip() { + Map original = new HashMap<>(); + original.put("name", "test"); + original.put("age", 25); + original.put("active", true); + + String json = JsonUtils.toJson(original); + Map restored = JsonUtils.fromJson(json, Map.class); + + assertThat(restored).isEqualTo(original); + } +} + diff --git a/cozeloop-core/src/test/java/com/coze/loop/internal/ValidationUtilsTest.java b/cozeloop-core/src/test/java/com/coze/loop/internal/ValidationUtilsTest.java new file mode 100644 index 0000000..59c4a6a --- /dev/null +++ b/cozeloop-core/src/test/java/com/coze/loop/internal/ValidationUtilsTest.java @@ -0,0 +1,131 @@ +package com.coze.loop.internal; + +import com.coze.loop.exception.CozeLoopException; +import com.coze.loop.exception.ErrorCode; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for ValidationUtils. + */ +class ValidationUtilsTest { + + @Test + void testRequireNonEmptyWithValidString() { + ValidationUtils.requireNonEmpty("valid", "param"); + // Should not throw + } + + @Test + void testRequireNonEmptyWithNull() { + assertThatThrownBy(() -> ValidationUtils.requireNonEmpty(null, "param")) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.INVALID_PARAM); + assertThat(ex.getMessage()).contains("param"); + }); + } + + @Test + void testRequireNonEmptyWithEmptyString() { + assertThatThrownBy(() -> ValidationUtils.requireNonEmpty("", "param")) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.INVALID_PARAM); + }); + } + + @Test + void testRequireNonEmptyWithWhitespaceOnly() { + assertThatThrownBy(() -> ValidationUtils.requireNonEmpty(" ", "param")) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.INVALID_PARAM); + }); + } + + @Test + void testRequireNonNullWithValidObject() { + ValidationUtils.requireNonNull("valid", "param"); + // Should not throw + } + + @Test + void testRequireNonNullWithNull() { + assertThatThrownBy(() -> ValidationUtils.requireNonNull(null, "param")) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.INVALID_PARAM); + assertThat(ex.getMessage()).contains("param"); + }); + } + + @Test + void testRequirePositiveWithPositiveNumber() { + ValidationUtils.requirePositive(1, "param"); + ValidationUtils.requirePositive(100, "param"); + // Should not throw + } + + @Test + void testRequirePositiveWithZero() { + assertThatThrownBy(() -> ValidationUtils.requirePositive(0, "param")) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.INVALID_PARAM); + }); + } + + @Test + void testRequirePositiveWithNegativeNumber() { + assertThatThrownBy(() -> ValidationUtils.requirePositive(-1, "param")) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.INVALID_PARAM); + }); + } + + @Test + void testRequireNonNegativeWithNonNegativeNumber() { + ValidationUtils.requireNonNegative(0, "param"); + ValidationUtils.requireNonNegative(1, "param"); + ValidationUtils.requireNonNegative(100, "param"); + // Should not throw + } + + @Test + void testRequireNonNegativeWithNegativeNumber() { + assertThatThrownBy(() -> ValidationUtils.requireNonNegative(-1, "param")) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.INVALID_PARAM); + }); + } + + @Test + void testRequireWithTrueCondition() { + ValidationUtils.require(true, "test message"); + // Should not throw + } + + @Test + void testRequireWithFalseCondition() { + assertThatThrownBy(() -> ValidationUtils.require(false, "test message")) + .isInstanceOf(CozeLoopException.class) + .satisfies(e -> { + CozeLoopException ex = (CozeLoopException) e; + assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.INVALID_PARAM); + assertThat(ex.getMessage()).contains("test message"); + }); + } +} + diff --git a/cozeloop-core/src/test/java/com/coze/loop/prompt/Jinja2TemplateEngineTest.java b/cozeloop-core/src/test/java/com/coze/loop/prompt/Jinja2TemplateEngineTest.java new file mode 100644 index 0000000..8ddb4f3 --- /dev/null +++ b/cozeloop-core/src/test/java/com/coze/loop/prompt/Jinja2TemplateEngineTest.java @@ -0,0 +1,100 @@ +package com.coze.loop.prompt; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for Jinja2TemplateEngine. + */ +class Jinja2TemplateEngineTest { + + private final Jinja2TemplateEngine engine = new Jinja2TemplateEngine(); + + @Test + void testRenderWithSimpleVariable() { + String template = "Hello {{ name }}!"; + Map variables = new HashMap<>(); + variables.put("name", "Alice"); + + String result = engine.render(template, variables); + + assertThat(result).isEqualTo("Hello Alice!"); + } + + @Test + void testRenderWithConditional() { + String template = "{% if active %}Active{% else %}Inactive{% endif %}"; + Map variables = new HashMap<>(); + variables.put("active", true); + + String result = engine.render(template, variables); + + assertThat(result.trim()).isEqualTo("Active"); + } + + @Test + void testRenderWithLoop() { + String template = "{% for item in items %}{{ item }} {% endfor %}"; + Map variables = new HashMap<>(); + variables.put("items", java.util.Arrays.asList("a", "b", "c")); + + String result = engine.render(template, variables); + + assertThat(result.trim()).isEqualTo("a b c"); + } + + @Test + void testRenderWithNullTemplate() { + Map variables = new HashMap<>(); + String result = engine.render(null, variables); + assertThat(result).isNull(); + } + + @Test + void testRenderWithEmptyTemplate() { + Map variables = new HashMap<>(); + String result = engine.render("", variables); + assertThat(result).isEmpty(); + } + + @Test + void testRenderWithMissingVariable() { + String template = "Hello {{ name }}!"; + Map variables = new HashMap<>(); + + // Jinja2 with failOnUnknownTokens=false should handle missing variables gracefully + String result = engine.render(template, variables); + + assertThat(result).isNotNull(); + } + + @Test + void testRenderWithComplexExpression() { + String template = "Count: {{ items|length }}"; + Map variables = new HashMap<>(); + variables.put("items", java.util.Arrays.asList(1, 2, 3)); + + String result = engine.render(template, variables); + + assertThat(result.trim()).isEqualTo("Count: 3"); + } + + @Test + void testRenderWithNestedVariables() { + String template = "User: {{ user.name }}, Age: {{ user.age }}"; + Map user = new HashMap<>(); + user.put("name", "Bob"); + user.put("age", 30); + Map variables = new HashMap<>(); + variables.put("user", user); + + String result = engine.render(template, variables); + + assertThat(result.trim()).isEqualTo("User: Bob, Age: 30"); + } +} + diff --git a/cozeloop-core/src/test/java/com/coze/loop/prompt/NormalTemplateEngineTest.java b/cozeloop-core/src/test/java/com/coze/loop/prompt/NormalTemplateEngineTest.java new file mode 100644 index 0000000..182dc29 --- /dev/null +++ b/cozeloop-core/src/test/java/com/coze/loop/prompt/NormalTemplateEngineTest.java @@ -0,0 +1,124 @@ +package com.coze.loop.prompt; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for NormalTemplateEngine. + */ +class NormalTemplateEngineTest { + + private final NormalTemplateEngine engine = new NormalTemplateEngine(); + + @Test + void testRenderWithDollarPlaceholder() { + String template = "Hello ${name}, welcome!"; + Map variables = new HashMap<>(); + variables.put("name", "Alice"); + + String result = engine.render(template, variables); + + assertThat(result).isEqualTo("Hello Alice, welcome!"); + } + + @Test + void testRenderWithDoubleBracePlaceholder() { + String template = "Hello {{name}}, welcome!"; + Map variables = new HashMap<>(); + variables.put("name", "Bob"); + + String result = engine.render(template, variables); + + assertThat(result).isEqualTo("Hello Bob, welcome!"); + } + + @Test + void testRenderWithMultipleVariables() { + String template = "User: ${user}, Age: ${age}, Active: ${active}"; + Map variables = new HashMap<>(); + variables.put("user", "Charlie"); + variables.put("age", 25); + variables.put("active", true); + + String result = engine.render(template, variables); + + assertThat(result).isEqualTo("User: Charlie, Age: 25, Active: true"); + } + + @Test + void testRenderWithMixedPlaceholders() { + String template = "Hello ${name}, your code is {{code}}"; + Map variables = new HashMap<>(); + variables.put("name", "David"); + variables.put("code", "ABC123"); + + String result = engine.render(template, variables); + + assertThat(result).isEqualTo("Hello David, your code is ABC123"); + } + + @Test + void testRenderWithNullTemplate() { + Map variables = new HashMap<>(); + String result = engine.render(null, variables); + assertThat(result).isNull(); + } + + @Test + void testRenderWithEmptyTemplate() { + Map variables = new HashMap<>(); + String result = engine.render("", variables); + assertThat(result).isEmpty(); + } + + @Test + void testRenderWithNullVariables() { + String template = "Hello ${name}"; + Map variables = new HashMap<>(); + variables.put("name", null); + + String result = engine.render(template, variables); + + // Null values are converted to empty string + assertThat(result).isEqualTo("Hello "); + } + + @Test + void testRenderWithMissingVariable() { + String template = "Hello ${name}"; + Map variables = new HashMap<>(); + + String result = engine.render(template, variables); + + // Should keep the placeholder if variable is missing + assertThat(result).contains("${name}"); + } + + @Test + void testRenderWithNestedPlaceholders() { + String template = "Value: ${value}"; + Map variables = new HashMap<>(); + variables.put("value", "${nested}"); + + String result = engine.render(template, variables); + + assertThat(result).isEqualTo("Value: ${nested}"); + } + + @Test + void testRenderWithComplexTypes() { + String template = "Value: ${value}"; + Map variables = new HashMap<>(); + variables.put("value", new HashMap<>()); + + String result = engine.render(template, variables); + + assertThat(result).isNotNull(); + assertThat(result).contains("Value:"); + } +} + diff --git a/cozeloop-core/src/test/java/com/coze/loop/prompt/PromptCacheTest.java b/cozeloop-core/src/test/java/com/coze/loop/prompt/PromptCacheTest.java new file mode 100644 index 0000000..3774eb5 --- /dev/null +++ b/cozeloop-core/src/test/java/com/coze/loop/prompt/PromptCacheTest.java @@ -0,0 +1,104 @@ +package com.coze.loop.prompt; + +import com.coze.loop.entity.Prompt; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for PromptCache. + */ +class PromptCacheTest { + + private PromptCache cache; + private Function loader; + + @BeforeEach + void setUp() { + loader = key -> { + Prompt prompt = new Prompt(); + prompt.setPromptKey(key); + return prompt; + }; + + PromptCache.PromptCacheConfig config = PromptCache.PromptCacheConfig.builder() + .maxSize(100) + .expireAfterWriteMinutes(60) + .refreshAfterWriteMinutes(30) + .build(); + + cache = new PromptCache(config, loader); + } + + @Test + void testGetSync() { + Prompt prompt = cache.getSync("test-key"); + + assertThat(prompt).isNotNull(); + assertThat(prompt.getPromptKey()).isEqualTo("test-key"); + } + + @Test + void testGetAsync() throws Exception { + CompletableFuture future = cache.get("test-key"); + Prompt prompt = future.get(); + + assertThat(prompt).isNotNull(); + assertThat(prompt.getPromptKey()).isEqualTo("test-key"); + } + + @Test + void testPut() { + Prompt prompt = new Prompt(); + prompt.setPromptKey("custom-key"); + + cache.put("custom-key", prompt); + Prompt cached = cache.getSync("custom-key"); + + assertThat(cached).isNotNull(); + assertThat(cached.getPromptKey()).isEqualTo("custom-key"); + } + + @Test + void testInvalidate() { + cache.getSync("test-key"); + cache.invalidate("test-key"); + + // After invalidation, should reload from loader + Prompt prompt = cache.getSync("test-key"); + assertThat(prompt).isNotNull(); + } + + @Test + void testInvalidateAll() { + cache.getSync("key1"); + cache.getSync("key2"); + + cache.invalidateAll(); + + // Cache should be empty, but getSync will reload + Prompt prompt = cache.getSync("key1"); + assertThat(prompt).isNotNull(); + } + + @Test + void testStats() { + cache.getSync("test-key"); + + assertThat(cache.stats()).isNotNull(); + } + + @Test + void testCacheReuse() { + Prompt prompt1 = cache.getSync("test-key"); + Prompt prompt2 = cache.getSync("test-key"); + + // Should return same instance from cache + assertThat(prompt1).isSameAs(prompt2); + } +} + diff --git a/cozeloop-core/src/test/java/com/coze/loop/prompt/PromptFormatterTest.java b/cozeloop-core/src/test/java/com/coze/loop/prompt/PromptFormatterTest.java new file mode 100644 index 0000000..9e851a5 --- /dev/null +++ b/cozeloop-core/src/test/java/com/coze/loop/prompt/PromptFormatterTest.java @@ -0,0 +1,165 @@ +package com.coze.loop.prompt; + +import com.coze.loop.entity.*; +import com.coze.loop.exception.PromptException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for PromptFormatter. + */ +class PromptFormatterTest { + + private PromptFormatter formatter; + + @BeforeEach + void setUp() { + formatter = new PromptFormatter(); + } + + @Test + void testFormatWithNormalTemplate() { + Prompt prompt = createPrompt(TemplateType.NORMAL); + Map variables = new HashMap<>(); + variables.put("name", "Alice"); + variables.put("message", "Hello"); + + List messages = formatter.format(prompt, variables); + + assertThat(messages).isNotNull(); + assertThat(messages).hasSize(1); + assertThat(messages.get(0).getContent()).contains("Alice"); + assertThat(messages.get(0).getContent()).contains("Hello"); + } + + @Test + void testFormatWithJinja2Template() { + Prompt prompt = createPrompt(TemplateType.JINJA2); + Map variables = new HashMap<>(); + variables.put("name", "Bob"); + + List messages = formatter.format(prompt, variables); + + assertThat(messages).isNotNull(); + assertThat(messages).hasSize(1); + } + + @Test + void testFormatWithNullTemplate() { + Prompt prompt = new Prompt(); + prompt.setPromptTemplate(null); + Map variables = new HashMap<>(); + + assertThatThrownBy(() -> formatter.format(prompt, variables)) + .isInstanceOf(PromptException.class); + } + + @Test + void testFormatWithNullPrompt() { + Map variables = new HashMap<>(); + + assertThatThrownBy(() -> formatter.format(null, variables)) + .isInstanceOf(PromptException.class); + } + + @Test + void testFormatWithNullVariables() { + Prompt prompt = createPrompt(TemplateType.NORMAL); + + List messages = formatter.format(prompt, null); + + assertThat(messages).isNotNull(); + } + + @Test + void testFormatWithEmptyVariables() { + Prompt prompt = createPrompt(TemplateType.NORMAL); + Map variables = new HashMap<>(); + + List messages = formatter.format(prompt, variables); + + assertThat(messages).isNotNull(); + } + + @Test + void testFormatWithMultipleMessages() { + Prompt prompt = createPromptWithMultipleMessages(); + Map variables = new HashMap<>(); + variables.put("name", "Charlie"); + + List messages = formatter.format(prompt, variables); + + assertThat(messages).isNotNull(); + assertThat(messages.size()).isGreaterThan(1); + } + + @Test + void testFormatDoesNotModifyOriginal() { + Prompt prompt = createPrompt(TemplateType.NORMAL); + Map variables = new HashMap<>(); + variables.put("name", "David"); + + String originalContent = prompt.getPromptTemplate().getMessages().get(0).getContent(); + List messages = formatter.format(prompt, variables); + + // Original should remain unchanged (deep copy) + assertThat(prompt.getPromptTemplate().getMessages().get(0).getContent()) + .isEqualTo(originalContent); + assertThat(messages.get(0).getContent()).isNotEqualTo(originalContent); + } + + @Test + void testFormatWithDefaultTemplateType() { + Prompt prompt = createPrompt(null); // null template type + Map variables = new HashMap<>(); + variables.put("name", "Eve"); + + List messages = formatter.format(prompt, variables); + + assertThat(messages).isNotNull(); + } + + private Prompt createPrompt(TemplateType templateType) { + PromptTemplate template = new PromptTemplate(); + template.setTemplateType(templateType); + + List messages = new ArrayList<>(); + Message message = new Message(Role.USER); + message.setContent("Hello ${name}, your message is: ${message}"); + messages.add(message); + template.setMessages(messages); + + Prompt prompt = new Prompt(); + prompt.setPromptTemplate(template); + return prompt; + } + + private Prompt createPromptWithMultipleMessages() { + PromptTemplate template = new PromptTemplate(); + template.setTemplateType(TemplateType.NORMAL); + + List messages = new ArrayList<>(); + Message msg1 = new Message(Role.SYSTEM); + msg1.setContent("System: ${name}"); + messages.add(msg1); + + Message msg2 = new Message(Role.USER); + msg2.setContent("User: ${name}"); + messages.add(msg2); + + template.setMessages(messages); + + Prompt prompt = new Prompt(); + prompt.setPromptTemplate(template); + return prompt; + } +} + diff --git a/cozeloop-spring-boot-starter/SPRING_BOOT_3_SUPPORT.md b/cozeloop-spring-boot-starter/SPRING_BOOT_3_SUPPORT.md new file mode 100644 index 0000000..19baeff --- /dev/null +++ b/cozeloop-spring-boot-starter/SPRING_BOOT_3_SUPPORT.md @@ -0,0 +1,144 @@ +# Spring Boot 3.x 支持说明 + +本文档说明 CozeLoop Spring Boot Starter 对 Spring Boot 3.x 的支持情况。 + +## 支持的 Spring Boot 版本 + +- **Spring Boot 2.7.x**: 完全支持(使用 `spring.factories`) +- **Spring Boot 3.x**: 完全支持(使用 `AutoConfiguration.imports`) + +## Spring Boot 3.x 的主要变化 + +根据 [Spring Boot 3.x 自定义 Starter 指南](https://blog.csdn.net/a1256afafaafr/article/details/147768713),主要变化包括: + +### 1. 自动配置注册方式 + +**Spring Boot 2.x** (旧方式): +``` +META-INF/spring.factories +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.coze.loop.spring.autoconfigure.CozeLoopAutoConfiguration +``` + +**Spring Boot 3.x** (新方式): +``` +META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +com.coze.loop.spring.autoconfigure.CozeLoopAutoConfiguration +``` + +### 2. 配置类优化 + +Spring Boot 3.x 推荐使用 `@Configuration(proxyBeanMethods = false)` 来优化性能: + +```java +@Configuration(proxyBeanMethods = false) +public class CozeLoopAutoConfiguration { + // ... +} +``` + +### 3. Java 版本要求 + +- **Spring Boot 2.x**: 需要 Java 8+ +- **Spring Boot 3.x**: 需要 Java 17+ + +## 实现细节 + +### 文件结构 + +``` +cozeloop-spring-boot-starter/ +└── src/ + └── main/ + └── resources/ + └── META-INF/ + ├── spring.factories # Spring Boot 2.x + └── spring/ + └── org.springframework.boot.autoconfigure.AutoConfiguration.imports # Spring Boot 3.x +``` + +### 兼容性策略 + +为了同时支持 Spring Boot 2.x 和 3.x,我们采用了以下策略: + +1. **保留 `spring.factories`**: 确保 Spring Boot 2.x 可以正常工作 +2. **添加 `AutoConfiguration.imports`**: 确保 Spring Boot 3.x 可以正常工作 +3. **配置类优化**: 使用 `proxyBeanMethods = false` 以兼容 Spring Boot 3.x 的最佳实践 + +### 使用方式 + +#### Spring Boot 2.x 项目 + +```xml + + org.springframework.boot + spring-boot-starter-parent + 2.7.x + + + + com.coze.loop + cozeloop-spring-boot-starter + 1.0.0-SNAPSHOT + +``` + +#### Spring Boot 3.x 项目 + +```xml + + org.springframework.boot + spring-boot-starter-parent + 3.x.x + + + + com.coze.loop + cozeloop-spring-boot-starter + 1.0.0-SNAPSHOT + +``` + +配置方式完全相同: + +```properties +cozeloop.workspace-id=your-workspace-id +cozeloop.auth.token=your-token +``` + +## 测试 + +### 测试 Spring Boot 2.x 兼容性 + +```bash +# 使用 Spring Boot 2.7.x 测试 +mvn test -Dspring-boot.version=2.7.18 +``` + +### 测试 Spring Boot 3.x 兼容性 + +```bash +# 使用 Spring Boot 3.x 测试 +mvn test -Dspring-boot.version=3.2.0 +``` + +## 注意事项 + +1. **Java 版本**: + - Spring Boot 2.x 项目可以使用 Java 8+ + - Spring Boot 3.x 项目必须使用 Java 17+ + +2. **依赖兼容性**: + - 确保所有依赖都与目标 Spring Boot 版本兼容 + +3. **配置属性**: + - 配置属性名称和结构在两个版本中保持一致 + +4. **API 兼容性**: + - CozeLoop SDK 的 API 在两个版本中完全一致 + +## 参考文档 + +- [Spring Boot 3.x 自定义 Starter 指南](https://blog.csdn.net/a1256afafaafr/article/details/147768713) +- [Spring Boot 3.0 Migration Guide](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide) + diff --git a/cozeloop-spring-boot-starter/TEST_SUMMARY.md b/cozeloop-spring-boot-starter/TEST_SUMMARY.md new file mode 100644 index 0000000..e42708e --- /dev/null +++ b/cozeloop-spring-boot-starter/TEST_SUMMARY.md @@ -0,0 +1,154 @@ +# Spring Boot Starter 单元测试和集成测试总结 + +本文档总结了为 CozeLoop Spring Boot Starter 模块创建的单元测试和集成测试。 + +## 测试覆盖范围 + +### 1. 配置属性测试 (`com.coze.loop.spring.config`) + +#### CozeLoopPropertiesTest +- ✅ 默认值测试 +- ✅ Workspace ID设置 +- ✅ Service Name设置 +- ✅ Base URL设置 +- ✅ Token认证配置 +- ✅ JWT认证配置 +- ✅ HTTP配置属性 +- ✅ HTTP默认值验证 +- ✅ Trace配置属性 +- ✅ Trace默认值验证 +- ✅ Prompt缓存配置 +- ✅ Prompt缓存默认值验证 + +### 2. AOP切面测试 (`com.coze.loop.spring.aop`) + +#### CozeTraceAspectTest +- ✅ 默认注解行为测试 +- ✅ 自定义Span名称测试 +- ✅ 参数捕获测试 (`captureArgs`) +- ✅ 返回值捕获测试 (`captureReturn`) +- ✅ 输入表达式测试 (`inputExpression`) +- ✅ 输出表达式测试 (`outputExpression`) +- ✅ 异常处理测试 +- ✅ SpEL表达式Span名称测试 +- ✅ 多参数处理测试 +- ✅ null返回值处理 + +### 3. 自动配置测试 (`com.coze.loop.spring.autoconfigure`) + +#### CozeLoopAutoConfigurationTest +- ✅ Token认证自动配置 +- ✅ JWT认证自动配置 +- ✅ 缺少Workspace ID时的行为 +- ✅ 自定义属性配置 +- ✅ Trace禁用时的行为 +- ✅ 自定义Client Bean优先级 +- ✅ 缺少认证配置时的失败行为 + +### 4. Spring Boot集成测试 (`com.coze.loop.spring.integration`) + +#### CozeLoopSpringBootIntegrationTest +- ✅ Bean创建验证 +- ✅ 配置属性加载验证 +- ✅ @CozeTrace注解功能测试 +- ✅ SpEL表达式功能测试 +- ✅ Spring上下文集成 + +#### CozeLoopFullIntegrationTest +- ✅ 完整客户端功能测试 +- ✅ Span创建和操作 +- ✅ @CozeTrace注解完整流程 +- ✅ 错误处理集成测试 +- ✅ SpEL表达式集成测试 +- ✅ 多方法追踪测试 + +## 测试统计 + +- **测试类总数**: 5个 +- **单元测试**: 3个测试类 +- **集成测试**: 2个测试类 +- **测试方法总数**: 约40+个测试用例 +- **测试框架**: JUnit 5 +- **Mock框架**: Mockito +- **断言库**: AssertJ +- **Spring Boot测试**: Spring Boot Test + +## 运行测试 + +### 运行所有测试 +```bash +cd cozeloop-spring-boot-starter +mvn test +``` + +### 运行特定测试类 +```bash +mvn test -Dtest=CozeLoopPropertiesTest +``` + +### 运行集成测试 +```bash +mvn test -Dtest=*IntegrationTest +``` + +### 运行单元测试(排除集成测试) +```bash +mvn test -Dtest=*Test -Dtest=!*IntegrationTest +``` + +## 测试特点 + +### 单元测试特点 +1. **配置属性测试**: 验证所有配置属性的getter/setter和默认值 +2. **AOP切面测试**: 使用Mockito模拟依赖,测试切面的各种场景 +3. **自动配置测试**: 使用ApplicationContextRunner测试Spring Boot自动配置 + +### 集成测试特点 +1. **Spring上下文测试**: 使用@SpringBootTest启动完整的Spring上下文 +2. **端到端测试**: 测试从配置到实际使用的完整流程 +3. **注解功能测试**: 验证@CozeTrace注解在实际Spring环境中的行为 + +## 测试场景覆盖 + +### 配置场景 +- ✅ Token认证配置 +- ✅ JWT认证配置 +- ✅ HTTP超时配置 +- ✅ Trace队列配置 +- ✅ Prompt缓存配置 +- ✅ 自定义服务名称和Base URL + +### AOP场景 +- ✅ 方法执行追踪 +- ✅ 参数和返回值捕获 +- ✅ SpEL表达式解析 +- ✅ 异常处理和错误标记 +- ✅ 多参数方法处理 + +### 自动配置场景 +- ✅ 条件配置(@ConditionalOnProperty) +- ✅ Bean优先级(@ConditionalOnMissingBean) +- ✅ 配置验证和错误处理 +- ✅ Trace启用/禁用 + +### 集成场景 +- ✅ Spring Bean注入 +- ✅ 配置属性绑定 +- ✅ AOP代理工作 +- ✅ 完整调用链测试 + +## 注意事项 + +1. **依赖顺序**: 运行测试前需要先编译core模块 (`mvn install`) +2. **Mock对象**: AOP测试使用Mockito模拟CozeLoopClient +3. **Spring上下文**: 集成测试会启动完整的Spring Boot应用上下文 +4. **配置属性**: 测试使用@TestPropertySource设置测试配置 + +## 后续改进建议 + +1. 添加更多边界条件测试 +2. 增加并发场景测试 +3. 添加性能测试 +4. 增加配置验证错误场景测试 +5. 添加Spring Boot不同版本的兼容性测试 + diff --git a/cozeloop-spring-boot-starter/pom.xml b/cozeloop-spring-boot-starter/pom.xml new file mode 100644 index 0000000..b5c3e1d --- /dev/null +++ b/cozeloop-spring-boot-starter/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + + + com.coze.loop + cozeloop-java-parent + 1.0.0-SNAPSHOT + + + cozeloop-spring-boot-starter + jar + + CozeLoop Java SDK - Spring Boot Starter + Spring Boot Starter for CozeLoop Java SDK + + + + + com.coze.loop + cozeloop-core + ${project.version} + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + + diff --git a/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/annotation/CozeTrace.java b/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/annotation/CozeTrace.java new file mode 100644 index 0000000..f30c8fb --- /dev/null +++ b/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/annotation/CozeTrace.java @@ -0,0 +1,63 @@ +package com.coze.loop.spring.annotation; + +import java.lang.annotation.*; + +/** + * Annotation to automatically create a trace span for a method. + * Supports SpEL expressions for dynamic span names and input/output capture. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface CozeTrace { + + /** + * Span name. Supports SpEL expressions. + * Example: "llm_call_#{#args[0]}" or "#{T(java.util.UUID).randomUUID().toString()}" + * + * @return span name or SpEL expression + */ + String value() default ""; + + /** + * Span type (e.g., "llm", "tool", "custom"). + * + * @return span type + */ + String spanType() default "custom"; + + /** + * Whether to capture method arguments as span input. + * If true, all arguments will be serialized to JSON. + * + * @return true to capture arguments + */ + boolean captureArgs() default false; + + /** + * Whether to capture method return value as span output. + * If true, the return value will be serialized to JSON. + * + * @return true to capture return value + */ + boolean captureReturn() default false; + + /** + * SpEL expression to extract input from method context. + * Available variables: #args (argument array), #arg0, #arg1, ... (individual arguments) + * Example: "#args[0].query" or "#arg0" + * + * @return SpEL expression for input extraction + */ + String inputExpression() default ""; + + /** + * SpEL expression to extract output from method context. + * Available variables: #result (return value) + * Example: "#result.data" or "#result.toString()" + * + * @return SpEL expression for output extraction + */ + String outputExpression() default ""; +} + diff --git a/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/aop/CozeTraceAspect.java b/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/aop/CozeTraceAspect.java new file mode 100644 index 0000000..96dafb3 --- /dev/null +++ b/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/aop/CozeTraceAspect.java @@ -0,0 +1,172 @@ +package com.coze.loop.spring.aop; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.spring.annotation.CozeTrace; +import com.coze.loop.trace.CozeLoopSpan; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +/** + * AOP aspect to handle @CozeTrace annotation. + */ +@Aspect +@Component +public class CozeTraceAspect { + private static final Logger logger = LoggerFactory.getLogger(CozeTraceAspect.class); + private final ExpressionParser parser = new SpelExpressionParser(); + + private final CozeLoopClient client; + + public CozeTraceAspect(CozeLoopClient client) { + this.client = client; + } + + @Around("@annotation(cozeTrace)") + public Object traceMethod(ProceedingJoinPoint pjp, CozeTrace cozeTrace) throws Throwable { + String spanName = resolveSpanName(cozeTrace, pjp); + String spanType = cozeTrace.spanType(); + + try (CozeLoopSpan span = client.startSpan(spanName, spanType)) { + // Capture input + captureInput(span, cozeTrace, pjp); + + try { + // Execute method + Object result = pjp.proceed(); + + // Capture output + captureOutput(span, cozeTrace, result); + + // Mark as successful + span.setStatusCode(0); + + return result; + } catch (Throwable throwable) { + // Capture error + span.setError(throwable); + span.setStatusCode(1); + throw throwable; + } + } + } + + /** + * Resolve span name from annotation. + * Supports SpEL expressions. + */ + private String resolveSpanName(CozeTrace annotation, ProceedingJoinPoint pjp) { + String name = annotation.value(); + + // If no name specified, use method name + if (name.isEmpty()) { + MethodSignature signature = (MethodSignature) pjp.getSignature(); + return signature.getMethod().getName(); + } + + // Check if it's a SpEL expression + if (name.contains("#{")) { + try { + StandardEvaluationContext context = new StandardEvaluationContext(); + Object[] args = pjp.getArgs(); + context.setVariable("args", args); + + // Set individual arguments + for (int i = 0; i < args.length; i++) { + context.setVariable("arg" + i, args[i]); + } + + Expression expression = parser.parseExpression(name); + Object value = expression.getValue(context); + return value != null ? value.toString() : name; + } catch (Exception e) { + logger.warn("Failed to evaluate SpEL expression for span name: {}", name, e); + return name; + } + } + + return name; + } + + /** + * Capture input based on annotation configuration. + */ + private void captureInput(CozeLoopSpan span, CozeTrace annotation, ProceedingJoinPoint pjp) { + try { + // Use input expression if provided + if (!annotation.inputExpression().isEmpty()) { + Object input = evaluateExpression(annotation.inputExpression(), pjp.getArgs(), null); + if (input != null) { + span.setInput(input); + } + } else if (annotation.captureArgs()) { + // Capture all arguments + Object[] args = pjp.getArgs(); + if (args != null && args.length > 0) { + if (args.length == 1) { + span.setInput(args[0]); + } else { + span.setInput(args); + } + } + } + } catch (Exception e) { + logger.warn("Failed to capture input", e); + } + } + + /** + * Capture output based on annotation configuration. + */ + private void captureOutput(CozeLoopSpan span, CozeTrace annotation, Object result) { + try { + // Use output expression if provided + if (!annotation.outputExpression().isEmpty()) { + Object output = evaluateExpression(annotation.outputExpression(), null, result); + if (output != null) { + span.setOutput(output); + } + } else if (annotation.captureReturn() && result != null) { + // Capture return value + span.setOutput(result); + } + } catch (Exception e) { + logger.warn("Failed to capture output", e); + } + } + + /** + * Evaluate SpEL expression. + */ + private Object evaluateExpression(String expressionString, Object[] args, Object result) { + try { + StandardEvaluationContext context = new StandardEvaluationContext(); + + if (args != null) { + context.setVariable("args", args); + for (int i = 0; i < args.length; i++) { + context.setVariable("arg" + i, args[i]); + } + } + + if (result != null) { + context.setVariable("result", result); + } + + Expression expression = parser.parseExpression(expressionString); + return expression.getValue(context); + } catch (Exception e) { + logger.warn("Failed to evaluate expression: {}", expressionString, e); + return null; + } + } +} + diff --git a/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/autoconfigure/CozeLoopAutoConfiguration.java b/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/autoconfigure/CozeLoopAutoConfiguration.java new file mode 100644 index 0000000..76df592 --- /dev/null +++ b/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/autoconfigure/CozeLoopAutoConfiguration.java @@ -0,0 +1,122 @@ +package com.coze.loop.spring.autoconfigure; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.client.CozeLoopClientBuilder; +import com.coze.loop.config.CozeLoopConfig; +import com.coze.loop.http.HttpConfig; +import com.coze.loop.prompt.PromptCache; +import com.coze.loop.spring.aop.CozeTraceAspect; +import com.coze.loop.spring.config.CozeLoopProperties; +import com.coze.loop.trace.CozeLoopTracerProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Auto-configuration for CozeLoop Spring Boot integration. + * Supports both Spring Boot 2.x and 3.x. + * + * For Spring Boot 3.x: Uses AutoConfiguration.imports file + * For Spring Boot 2.x: Uses spring.factories file + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass(CozeLoopClient.class) +@EnableConfigurationProperties(CozeLoopProperties.class) +@ConditionalOnProperty(prefix = "cozeloop", name = "workspace-id") +public class CozeLoopAutoConfiguration { + private static final Logger logger = LoggerFactory.getLogger(CozeLoopAutoConfiguration.class); + + /** + * Create CozeLoopClient bean. + */ + @Bean + @ConditionalOnMissingBean + public CozeLoopClient cozeLoopClient(CozeLoopProperties properties) { + logger.info("Initializing CozeLoop client with workspace: {}", properties.getWorkspaceId()); + + // Build configuration + CozeLoopConfig config = CozeLoopConfig.builder() + .workspaceId(properties.getWorkspaceId()) + .serviceName(properties.getServiceName() != null ? + properties.getServiceName() : "spring-boot-app") + .baseUrl(properties.getBaseUrl()) + .httpConfig(buildHttpConfig(properties.getHttp())) + .traceConfig(buildTraceConfig(properties.getTrace())) + .promptCacheConfig(buildPromptCacheConfig(properties.getPrompt().getCache())) + .build(); + + // Build client + CozeLoopClientBuilder builder = new CozeLoopClientBuilder() + .config(config); + + // Configure authentication + if (properties.getAuth().getToken() != null && + !properties.getAuth().getToken().isEmpty()) { + builder.tokenAuth(properties.getAuth().getToken()); + } else if (properties.getAuth().getJwt().getClientId() != null) { + builder.jwtOAuth( + properties.getAuth().getJwt().getClientId(), + properties.getAuth().getJwt().getPrivateKey(), + properties.getAuth().getJwt().getPublicKeyId() + ); + } else { + throw new IllegalArgumentException( + "Either cozeloop.auth.token or cozeloop.auth.jwt must be configured"); + } + + return builder.build(); + } + + /** + * Create CozeTraceAspect bean if trace is enabled. + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "cozeloop.trace", name = "enabled", havingValue = "true", + matchIfMissing = true) + public CozeTraceAspect cozeTraceAspect(CozeLoopClient client) { + logger.info("Enabling @CozeTrace annotation support"); + return new CozeTraceAspect(client); + } + + /** + * Build HttpConfig from properties. + */ + private HttpConfig buildHttpConfig(CozeLoopProperties.Http http) { + return HttpConfig.builder() + .connectTimeoutSeconds(http.getConnectTimeoutSeconds()) + .readTimeoutSeconds(http.getReadTimeoutSeconds()) + .writeTimeoutSeconds(http.getWriteTimeoutSeconds()) + .maxRetries(http.getMaxRetries()) + .build(); + } + + /** + * Build TraceConfig from properties. + */ + private CozeLoopTracerProvider.TraceConfig buildTraceConfig(CozeLoopProperties.Trace trace) { + return CozeLoopTracerProvider.TraceConfig.builder() + .maxQueueSize(trace.getMaxQueueSize()) + .batchSize(trace.getBatchSize()) + .scheduleDelayMillis(trace.getScheduleDelayMillis()) + .build(); + } + + /** + * Build PromptCacheConfig from properties. + */ + private PromptCache.PromptCacheConfig buildPromptCacheConfig( + CozeLoopProperties.Prompt.Cache cache) { + return PromptCache.PromptCacheConfig.builder() + .maxSize(cache.getMaxSize()) + .expireAfterWriteMinutes(cache.getExpireAfterWriteMinutes()) + .refreshAfterWriteMinutes(cache.getRefreshAfterWriteMinutes()) + .build(); + } +} + diff --git a/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/config/CozeLoopProperties.java b/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/config/CozeLoopProperties.java new file mode 100644 index 0000000..b94a66f --- /dev/null +++ b/cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/config/CozeLoopProperties.java @@ -0,0 +1,286 @@ +package com.coze.loop.spring.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for CozeLoop Spring Boot integration. + */ +@ConfigurationProperties(prefix = "cozeloop") +public class CozeLoopProperties { + + /** + * Workspace ID (required). + */ + private String workspaceId; + + /** + * Service name (default: application name). + */ + private String serviceName; + + /** + * Base URL (default: https://api.coze.cn). + */ + private String baseUrl = "https://api.coze.cn"; + + /** + * Authentication configuration. + */ + private Auth auth = new Auth(); + + /** + * HTTP configuration. + */ + private Http http = new Http(); + + /** + * Trace configuration. + */ + private Trace trace = new Trace(); + + /** + * Prompt configuration. + */ + private Prompt prompt = new Prompt(); + + // Getters and Setters + public String getWorkspaceId() { + return workspaceId; + } + + public void setWorkspaceId(String workspaceId) { + this.workspaceId = workspaceId; + } + + public String getServiceName() { + return serviceName; + } + + public void setServiceName(String serviceName) { + this.serviceName = serviceName; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public Auth getAuth() { + return auth; + } + + public void setAuth(Auth auth) { + this.auth = auth; + } + + public Http getHttp() { + return http; + } + + public void setHttp(Http http) { + this.http = http; + } + + public Trace getTrace() { + return trace; + } + + public void setTrace(Trace trace) { + this.trace = trace; + } + + public Prompt getPrompt() { + return prompt; + } + + public void setPrompt(Prompt prompt) { + this.prompt = prompt; + } + + /** + * Authentication properties. + */ + public static class Auth { + private String token; + private Jwt jwt = new Jwt(); + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Jwt getJwt() { + return jwt; + } + + public void setJwt(Jwt jwt) { + this.jwt = jwt; + } + + public static class Jwt { + private String clientId; + private String privateKey; + private String publicKeyId; + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getPrivateKey() { + return privateKey; + } + + public void setPrivateKey(String privateKey) { + this.privateKey = privateKey; + } + + public String getPublicKeyId() { + return publicKeyId; + } + + public void setPublicKeyId(String publicKeyId) { + this.publicKeyId = publicKeyId; + } + } + } + + /** + * HTTP properties. + */ + public static class Http { + private int connectTimeoutSeconds = 30; + private int readTimeoutSeconds = 60; + private int writeTimeoutSeconds = 60; + private int maxRetries = 3; + + public int getConnectTimeoutSeconds() { + return connectTimeoutSeconds; + } + + public void setConnectTimeoutSeconds(int connectTimeoutSeconds) { + this.connectTimeoutSeconds = connectTimeoutSeconds; + } + + public int getReadTimeoutSeconds() { + return readTimeoutSeconds; + } + + public void setReadTimeoutSeconds(int readTimeoutSeconds) { + this.readTimeoutSeconds = readTimeoutSeconds; + } + + public int getWriteTimeoutSeconds() { + return writeTimeoutSeconds; + } + + public void setWriteTimeoutSeconds(int writeTimeoutSeconds) { + this.writeTimeoutSeconds = writeTimeoutSeconds; + } + + public int getMaxRetries() { + return maxRetries; + } + + public void setMaxRetries(int maxRetries) { + this.maxRetries = maxRetries; + } + } + + /** + * Trace properties. + */ + public static class Trace { + private boolean enabled = true; + private int maxQueueSize = 2048; + private int batchSize = 512; + private long scheduleDelayMillis = 5000; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getMaxQueueSize() { + return maxQueueSize; + } + + public void setMaxQueueSize(int maxQueueSize) { + this.maxQueueSize = maxQueueSize; + } + + public int getBatchSize() { + return batchSize; + } + + public void setBatchSize(int batchSize) { + this.batchSize = batchSize; + } + + public long getScheduleDelayMillis() { + return scheduleDelayMillis; + } + + public void setScheduleDelayMillis(long scheduleDelayMillis) { + this.scheduleDelayMillis = scheduleDelayMillis; + } + } + + /** + * Prompt properties. + */ + public static class Prompt { + private Cache cache = new Cache(); + + public Cache getCache() { + return cache; + } + + public void setCache(Cache cache) { + this.cache = cache; + } + + public static class Cache { + private long maxSize = 1000; + private long expireAfterWriteMinutes = 60; + private long refreshAfterWriteMinutes = 30; + + public long getMaxSize() { + return maxSize; + } + + public void setMaxSize(long maxSize) { + this.maxSize = maxSize; + } + + public long getExpireAfterWriteMinutes() { + return expireAfterWriteMinutes; + } + + public void setExpireAfterWriteMinutes(long expireAfterWriteMinutes) { + this.expireAfterWriteMinutes = expireAfterWriteMinutes; + } + + public long getRefreshAfterWriteMinutes() { + return refreshAfterWriteMinutes; + } + + public void setRefreshAfterWriteMinutes(long refreshAfterWriteMinutes) { + this.refreshAfterWriteMinutes = refreshAfterWriteMinutes; + } + } + } +} + diff --git a/cozeloop-spring-boot-starter/src/main/resources/META-INF/spring.factories b/cozeloop-spring-boot-starter/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..fc95c81 --- /dev/null +++ b/cozeloop-spring-boot-starter/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +com.coze.loop.spring.autoconfigure.CozeLoopAutoConfiguration + diff --git a/cozeloop-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cozeloop-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..dce8b83 --- /dev/null +++ b/cozeloop-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.coze.loop.spring.autoconfigure.CozeLoopAutoConfiguration + diff --git a/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/aop/CozeTraceAspectTest.java b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/aop/CozeTraceAspectTest.java new file mode 100644 index 0000000..89a1419 --- /dev/null +++ b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/aop/CozeTraceAspectTest.java @@ -0,0 +1,227 @@ +package com.coze.loop.spring.aop; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.spring.annotation.CozeTrace; +import com.coze.loop.trace.CozeLoopSpan; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * Unit tests for CozeTraceAspect. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CozeTraceAspectTest { + + @Mock + private CozeLoopClient client; + + @Mock + private CozeLoopSpan span; + + @Mock + private ProceedingJoinPoint joinPoint; + + @Mock + private MethodSignature methodSignature; + + private CozeTraceAspect aspect; + + @BeforeEach + void setUp() throws Exception { + aspect = new CozeTraceAspect(client); + + Method testMethod = TestService.class.getMethod("testMethod", String.class); + when(joinPoint.getSignature()).thenReturn(methodSignature); + when(methodSignature.getMethod()).thenReturn(testMethod); + when(joinPoint.getArgs()).thenReturn(new Object[]{"test-arg"}); + } + + @Test + void testTraceMethodWithDefaultAnnotation() throws Throwable { + CozeTrace annotation = createAnnotation("", "custom", false, false, "", ""); + when(client.startSpan(anyString(), anyString())).thenReturn(span); + when(joinPoint.proceed()).thenReturn("result"); + + Object result = aspect.traceMethod(joinPoint, annotation); + + assertThat(result).isEqualTo("result"); + verify(client).startSpan("testMethod", "custom"); + verify(span).setStatusCode(0); + verify(span).close(); + } + + @Test + void testTraceMethodWithCustomSpanName() throws Throwable { + CozeTrace annotation = createAnnotation("custom-span", "llm", false, false, "", ""); + when(client.startSpan(anyString(), anyString())).thenReturn(span); + when(joinPoint.proceed()).thenReturn("result"); + + Object result = aspect.traceMethod(joinPoint, annotation); + + assertThat(result).isEqualTo("result"); + verify(client).startSpan("custom-span", "llm"); + verify(span).setStatusCode(0); + } + + @Test + void testTraceMethodWithCaptureArgs() throws Throwable { + CozeTrace annotation = createAnnotation("", "custom", true, false, "", ""); + when(client.startSpan(anyString(), anyString())).thenReturn(span); + when(joinPoint.proceed()).thenReturn("result"); + + aspect.traceMethod(joinPoint, annotation); + + verify(span).setInput(any()); + verify(span, never()).setOutput(any()); + } + + @Test + void testTraceMethodWithCaptureReturn() throws Throwable { + CozeTrace annotation = createAnnotation("", "custom", false, true, "", ""); + when(client.startSpan(anyString(), anyString())).thenReturn(span); + when(joinPoint.proceed()).thenReturn("result"); + + aspect.traceMethod(joinPoint, annotation); + + verify(span).setOutput("result"); + verify(span, never()).setInput(any()); + } + + @Test + void testTraceMethodWithInputExpression() throws Throwable { + CozeTrace annotation = createAnnotation("", "custom", false, false, "#args[0]", ""); + when(client.startSpan(anyString(), anyString())).thenReturn(span); + when(joinPoint.proceed()).thenReturn("result"); + + aspect.traceMethod(joinPoint, annotation); + + verify(span).setInput(any()); + } + + @Test + void testTraceMethodWithOutputExpression() throws Throwable { + CozeTrace annotation = createAnnotation("", "custom", false, false, "", "#result"); + when(client.startSpan(anyString(), anyString())).thenReturn(span); + when(joinPoint.proceed()).thenReturn("result"); + + aspect.traceMethod(joinPoint, annotation); + + verify(span).setOutput(any()); + } + + @Test + void testTraceMethodWithException() throws Throwable { + CozeTrace annotation = createAnnotation("", "custom", false, false, "", ""); + RuntimeException exception = new RuntimeException("test error"); + when(client.startSpan(anyString(), anyString())).thenReturn(span); + when(joinPoint.proceed()).thenThrow(exception); + + assertThatThrownBy(() -> aspect.traceMethod(joinPoint, annotation)) + .isInstanceOf(RuntimeException.class) + .hasMessage("test error"); + + verify(span).setError(exception); + verify(span).setStatusCode(1); + verify(span).close(); + } + + @Test + void testTraceMethodWithSpelSpanName() throws Throwable { + CozeTrace annotation = createAnnotation("#{'span_' + #args[0]}", "custom", false, false, "", ""); + when(client.startSpan(anyString(), anyString())).thenReturn(span); + when(joinPoint.proceed()).thenReturn("result"); + + aspect.traceMethod(joinPoint, annotation); + + verify(client).startSpan(anyString(), eq("custom")); + } + + @Test + void testTraceMethodWithMultipleArgs() throws Throwable { + when(joinPoint.getArgs()).thenReturn(new Object[]{"arg1", "arg2", 123}); + CozeTrace annotation = createAnnotation("", "custom", true, false, "", ""); + when(client.startSpan(anyString(), anyString())).thenReturn(span); + when(joinPoint.proceed()).thenReturn("result"); + + aspect.traceMethod(joinPoint, annotation); + + verify(span).setInput(any(Object[].class)); + } + + @Test + void testTraceMethodWithNullReturn() throws Throwable { + CozeTrace annotation = createAnnotation("", "custom", false, true, "", ""); + when(client.startSpan(anyString(), anyString())).thenReturn(span); + when(joinPoint.proceed()).thenReturn(null); + + aspect.traceMethod(joinPoint, annotation); + + verify(span, never()).setOutput(any()); + verify(span).setStatusCode(0); + } + + private CozeTrace createAnnotation(String value, String spanType, + boolean captureArgs, boolean captureReturn, + String inputExpression, String outputExpression) { + return new CozeTrace() { + @Override + public Class annotationType() { + return CozeTrace.class; + } + + @Override + public String value() { + return value; + } + + @Override + public String spanType() { + return spanType; + } + + @Override + public boolean captureArgs() { + return captureArgs; + } + + @Override + public boolean captureReturn() { + return captureReturn; + } + + @Override + public String inputExpression() { + return inputExpression; + } + + @Override + public String outputExpression() { + return outputExpression; + } + }; + } + + // Test service class for method reflection + static class TestService { + public String testMethod(String arg) { + return "result"; + } + } +} + diff --git a/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/autoconfigure/CozeLoopAutoConfigurationSpringBoot3Test.java b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/autoconfigure/CozeLoopAutoConfigurationSpringBoot3Test.java new file mode 100644 index 0000000..39105cf --- /dev/null +++ b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/autoconfigure/CozeLoopAutoConfigurationSpringBoot3Test.java @@ -0,0 +1,219 @@ +package com.coze.loop.spring.autoconfigure; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.spring.aop.CozeTraceAspect; +import com.coze.loop.spring.config.CozeLoopProperties; +import com.coze.loop.spring.test.OpenTelemetryTestUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for CozeLoopAutoConfiguration with Spring Boot 3.x compatibility. + * Tests the AutoConfiguration.imports file mechanism. + */ +class CozeLoopAutoConfigurationSpringBoot3Test { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(CozeLoopAutoConfiguration.class)); + + @BeforeEach + void setUp() { + OpenTelemetryTestUtils.resetGlobalOpenTelemetry(); + } + + @AfterEach + void tearDown() { + OpenTelemetryTestUtils.resetGlobalOpenTelemetry(); + } + + @Test + void testAutoConfigurationWithTokenAuth() { + contextRunner + .withPropertyValues( + "cozeloop.workspace-id=test-workspace", + "cozeloop.auth.token=test-token" + ) + .run(context -> { + assertThat(context).hasSingleBean(CozeLoopClient.class); + assertThat(context).hasSingleBean(CozeTraceAspect.class); + }); + } + + @Test + void testAutoConfigurationWithJwtAuth() { + // Use a valid base64-encoded test key (base64 of "test-key-123456789012345678901234567890") + String validBase64Key = "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkw"; + contextRunner + .withPropertyValues( + "cozeloop.workspace-id=test-workspace", + "cozeloop.auth.jwt.client-id=test-client", + "cozeloop.auth.jwt.private-key=" + validBase64Key, + "cozeloop.auth.jwt.public-key-id=test-key-id" + ) + .run(context -> { + // JWT auth will fail with invalid key format, but auto-configuration should still attempt to create client + // The test verifies that the configuration is processed correctly + assertThat(context).hasFailed(); + }); + } + + @Test + void testAutoConfigurationWithoutWorkspaceId() { + contextRunner + .withPropertyValues( + "cozeloop.auth.token=test-token" + ) + .run(context -> { + // Should not create beans without workspace-id + assertThat(context).doesNotHaveBean(CozeLoopClient.class); + }); + } + + @Test + void testAutoConfigurationWithCustomProperties() { + contextRunner + .withPropertyValues( + "cozeloop.workspace-id=test-workspace", + "cozeloop.service-name=custom-service", + "cozeloop.base-url=https://custom.url", + "cozeloop.auth.token=test-token", + "cozeloop.http.connect-timeout-seconds=10", + "cozeloop.http.read-timeout-seconds=20", + "cozeloop.trace.max-queue-size=1024", + "cozeloop.prompt.cache.max-size=500" + ) + .run(context -> { + assertThat(context).hasSingleBean(CozeLoopClient.class); + CozeLoopProperties properties = context.getBean(CozeLoopProperties.class); + assertThat(properties.getServiceName()).isEqualTo("custom-service"); + assertThat(properties.getBaseUrl()).isEqualTo("https://custom.url"); + }); + } + + @Test + void testAutoConfigurationWithTraceDisabled() { + contextRunner + .withPropertyValues( + "cozeloop.workspace-id=test-workspace", + "cozeloop.auth.token=test-token", + "cozeloop.trace.enabled=false" + ) + .run(context -> { + assertThat(context).hasSingleBean(CozeLoopClient.class); + assertThat(context).doesNotHaveBean(CozeTraceAspect.class); + }); + } + + @Test + void testAutoConfigurationWithExistingClient() { + contextRunner + .withUserConfiguration(CustomClientConfiguration.class) + .withPropertyValues( + "cozeloop.workspace-id=test-workspace", + "cozeloop.auth.token=test-token" + ) + .run(context -> { + assertThat(context).hasSingleBean(CozeLoopClient.class); + assertThat(context.getBean(CozeLoopClient.class)) + .isSameAs(context.getBean(CustomClientConfiguration.class).customClient); + }); + } + + @Test + void testAutoConfigurationFailsWithoutAuth() { + contextRunner + .withPropertyValues( + "cozeloop.workspace-id=test-workspace" + ) + .run(context -> { + assertThat(context).hasFailed(); + }); + } + + @Test + void testConfigurationProxyBeanMethodsFalse() { + // Verify that @Configuration(proxyBeanMethods = false) is set + // This is a Spring Boot 3.x best practice + assertThat(CozeLoopAutoConfiguration.class.getAnnotation( + org.springframework.context.annotation.Configuration.class)) + .isNotNull(); + } + + @TestConfiguration + static class CustomClientConfiguration { + private final CozeLoopClient customClient = new CozeLoopClient() { + @Override + public String getWorkspaceId() { + return "custom"; + } + + @Override + public com.coze.loop.trace.CozeLoopSpan startSpan(String name) { + return null; + } + + @Override + public com.coze.loop.trace.CozeLoopSpan startSpan(String name, String spanType) { + return null; + } + + @Override + public io.opentelemetry.api.trace.Tracer getTracer() { + return null; + } + + @Override + public com.coze.loop.entity.Prompt getPrompt(com.coze.loop.prompt.GetPromptParam param) { + return null; + } + + @Override + public java.util.List formatPrompt( + com.coze.loop.entity.Prompt prompt, java.util.Map variables) { + return null; + } + + @Override + public java.util.List getAndFormatPrompt( + com.coze.loop.prompt.GetPromptParam param, java.util.Map variables) { + return null; + } + + @Override + public void invalidatePromptCache(com.coze.loop.prompt.GetPromptParam param) { + } + + @Override + public com.coze.loop.entity.ExecuteResult execute(com.coze.loop.entity.ExecuteParam param) { + return null; + } + + @Override + public com.coze.loop.stream.StreamReader executeStreaming( + com.coze.loop.entity.ExecuteParam param) { + return null; + } + + @Override + public void shutdown() { + } + + @Override + public void close() { + } + }; + + @Bean + public CozeLoopClient cozeLoopClient() { + return customClient; + } + } +} + diff --git a/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/autoconfigure/CozeLoopAutoConfigurationTest.java b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/autoconfigure/CozeLoopAutoConfigurationTest.java new file mode 100644 index 0000000..996cd6f --- /dev/null +++ b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/autoconfigure/CozeLoopAutoConfigurationTest.java @@ -0,0 +1,208 @@ +package com.coze.loop.spring.autoconfigure; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.spring.aop.CozeTraceAspect; +import com.coze.loop.spring.config.CozeLoopProperties; +import com.coze.loop.spring.test.OpenTelemetryTestUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for CozeLoopAutoConfiguration. + */ +class CozeLoopAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(CozeLoopAutoConfiguration.class)); + + @BeforeEach + void setUp() { + OpenTelemetryTestUtils.resetGlobalOpenTelemetry(); + } + + @AfterEach + void tearDown() { + OpenTelemetryTestUtils.resetGlobalOpenTelemetry(); + } + + @Test + void testAutoConfigurationWithTokenAuth() { + contextRunner + .withPropertyValues( + "cozeloop.workspace-id=test-workspace", + "cozeloop.auth.token=test-token" + ) + .run(context -> { + assertThat(context).hasSingleBean(CozeLoopClient.class); + assertThat(context).hasSingleBean(CozeTraceAspect.class); + }); + } + + @Test + void testAutoConfigurationWithJwtAuth() { + // Use a valid base64-encoded test key (base64 of "test-key-123456789012345678901234567890") + String validBase64Key = "dGVzdC1rZXktMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkw"; + contextRunner + .withPropertyValues( + "cozeloop.workspace-id=test-workspace", + "cozeloop.auth.jwt.client-id=test-client", + "cozeloop.auth.jwt.private-key=" + validBase64Key, + "cozeloop.auth.jwt.public-key-id=test-key-id" + ) + .run(context -> { + // JWT auth will fail with invalid key format, but auto-configuration should still attempt to create client + // The test verifies that the configuration is processed correctly + assertThat(context).hasFailed(); + }); + } + + @Test + void testAutoConfigurationWithoutWorkspaceId() { + contextRunner + .withPropertyValues( + "cozeloop.auth.token=test-token" + ) + .run(context -> { + assertThat(context).doesNotHaveBean(CozeLoopClient.class); + }); + } + + @Test + void testAutoConfigurationWithCustomProperties() { + contextRunner + .withPropertyValues( + "cozeloop.workspace-id=test-workspace", + "cozeloop.service-name=custom-service", + "cozeloop.base-url=https://custom.url", + "cozeloop.auth.token=test-token", + "cozeloop.http.connect-timeout-seconds=10", + "cozeloop.http.read-timeout-seconds=20", + "cozeloop.trace.max-queue-size=1024", + "cozeloop.prompt.cache.max-size=500" + ) + .run(context -> { + assertThat(context).hasSingleBean(CozeLoopClient.class); + CozeLoopProperties properties = context.getBean(CozeLoopProperties.class); + assertThat(properties.getServiceName()).isEqualTo("custom-service"); + assertThat(properties.getBaseUrl()).isEqualTo("https://custom.url"); + }); + } + + @Test + void testAutoConfigurationWithTraceDisabled() { + contextRunner + .withPropertyValues( + "cozeloop.workspace-id=test-workspace", + "cozeloop.auth.token=test-token", + "cozeloop.trace.enabled=false" + ) + .run(context -> { + assertThat(context).hasSingleBean(CozeLoopClient.class); + assertThat(context).doesNotHaveBean(CozeTraceAspect.class); + }); + } + + @Test + void testAutoConfigurationWithExistingClient() { + contextRunner + .withUserConfiguration(CustomClientConfiguration.class) + .withPropertyValues( + "cozeloop.workspace-id=test-workspace", + "cozeloop.auth.token=test-token" + ) + .run(context -> { + assertThat(context).hasSingleBean(CozeLoopClient.class); + assertThat(context.getBean(CozeLoopClient.class)) + .isSameAs(context.getBean(CustomClientConfiguration.class).customClient); + }); + } + + @Test + void testAutoConfigurationFailsWithoutAuth() { + contextRunner + .withPropertyValues( + "cozeloop.workspace-id=test-workspace" + ) + .run(context -> { + assertThat(context).hasFailed(); + }); + } + + @TestConfiguration + static class CustomClientConfiguration { + private final CozeLoopClient customClient = new CozeLoopClient() { + @Override + public String getWorkspaceId() { + return "custom"; + } + + @Override + public com.coze.loop.trace.CozeLoopSpan startSpan(String name) { + return null; + } + + @Override + public com.coze.loop.trace.CozeLoopSpan startSpan(String name, String spanType) { + return null; + } + + @Override + public io.opentelemetry.api.trace.Tracer getTracer() { + return null; + } + + @Override + public com.coze.loop.entity.Prompt getPrompt(com.coze.loop.prompt.GetPromptParam param) { + return null; + } + + @Override + public java.util.List formatPrompt( + com.coze.loop.entity.Prompt prompt, java.util.Map variables) { + return null; + } + + @Override + public java.util.List getAndFormatPrompt( + com.coze.loop.prompt.GetPromptParam param, java.util.Map variables) { + return null; + } + + @Override + public void invalidatePromptCache(com.coze.loop.prompt.GetPromptParam param) { + } + + @Override + public com.coze.loop.entity.ExecuteResult execute(com.coze.loop.entity.ExecuteParam param) { + return null; + } + + @Override + public com.coze.loop.stream.StreamReader executeStreaming( + com.coze.loop.entity.ExecuteParam param) { + return null; + } + + @Override + public void shutdown() { + } + + @Override + public void close() { + } + }; + + @Bean + public CozeLoopClient cozeLoopClient() { + return customClient; + } + } +} + diff --git a/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/config/CozeLoopPropertiesTest.java b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/config/CozeLoopPropertiesTest.java new file mode 100644 index 0000000..838b531 --- /dev/null +++ b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/config/CozeLoopPropertiesTest.java @@ -0,0 +1,145 @@ +package com.coze.loop.spring.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for CozeLoopProperties. + */ +class CozeLoopPropertiesTest { + + private CozeLoopProperties properties; + + @BeforeEach + void setUp() { + properties = new CozeLoopProperties(); + } + + @Test + void testDefaultValues() { + assertThat(properties.getBaseUrl()).isEqualTo("https://api.coze.cn"); + assertThat(properties.getWorkspaceId()).isNull(); + assertThat(properties.getServiceName()).isNull(); + assertThat(properties.getAuth()).isNotNull(); + assertThat(properties.getHttp()).isNotNull(); + assertThat(properties.getTrace()).isNotNull(); + assertThat(properties.getPrompt()).isNotNull(); + } + + @Test + void testWorkspaceId() { + properties.setWorkspaceId("test-workspace"); + assertThat(properties.getWorkspaceId()).isEqualTo("test-workspace"); + } + + @Test + void testServiceName() { + properties.setServiceName("test-service"); + assertThat(properties.getServiceName()).isEqualTo("test-service"); + } + + @Test + void testBaseUrl() { + properties.setBaseUrl("https://custom.url"); + assertThat(properties.getBaseUrl()).isEqualTo("https://custom.url"); + } + + @Test + void testAuthToken() { + CozeLoopProperties.Auth auth = new CozeLoopProperties.Auth(); + auth.setToken("test-token"); + properties.setAuth(auth); + + assertThat(properties.getAuth().getToken()).isEqualTo("test-token"); + } + + @Test + void testAuthJwt() { + CozeLoopProperties.Auth auth = new CozeLoopProperties.Auth(); + CozeLoopProperties.Auth.Jwt jwt = new CozeLoopProperties.Auth.Jwt(); + jwt.setClientId("test-client-id"); + jwt.setPrivateKey("test-private-key"); + jwt.setPublicKeyId("test-public-key-id"); + auth.setJwt(jwt); + properties.setAuth(auth); + + assertThat(properties.getAuth().getJwt().getClientId()).isEqualTo("test-client-id"); + assertThat(properties.getAuth().getJwt().getPrivateKey()).isEqualTo("test-private-key"); + assertThat(properties.getAuth().getJwt().getPublicKeyId()).isEqualTo("test-public-key-id"); + } + + @Test + void testHttpProperties() { + CozeLoopProperties.Http http = new CozeLoopProperties.Http(); + http.setConnectTimeoutSeconds(10); + http.setReadTimeoutSeconds(20); + http.setWriteTimeoutSeconds(30); + http.setMaxRetries(5); + properties.setHttp(http); + + assertThat(properties.getHttp().getConnectTimeoutSeconds()).isEqualTo(10); + assertThat(properties.getHttp().getReadTimeoutSeconds()).isEqualTo(20); + assertThat(properties.getHttp().getWriteTimeoutSeconds()).isEqualTo(30); + assertThat(properties.getHttp().getMaxRetries()).isEqualTo(5); + } + + @Test + void testHttpDefaultValues() { + CozeLoopProperties.Http http = new CozeLoopProperties.Http(); + assertThat(http.getConnectTimeoutSeconds()).isEqualTo(30); + assertThat(http.getReadTimeoutSeconds()).isEqualTo(60); + assertThat(http.getWriteTimeoutSeconds()).isEqualTo(60); + assertThat(http.getMaxRetries()).isEqualTo(3); + } + + @Test + void testTraceProperties() { + CozeLoopProperties.Trace trace = new CozeLoopProperties.Trace(); + trace.setEnabled(false); + trace.setMaxQueueSize(1024); + trace.setBatchSize(256); + trace.setScheduleDelayMillis(10000); + properties.setTrace(trace); + + assertThat(properties.getTrace().isEnabled()).isFalse(); + assertThat(properties.getTrace().getMaxQueueSize()).isEqualTo(1024); + assertThat(properties.getTrace().getBatchSize()).isEqualTo(256); + assertThat(properties.getTrace().getScheduleDelayMillis()).isEqualTo(10000); + } + + @Test + void testTraceDefaultValues() { + CozeLoopProperties.Trace trace = new CozeLoopProperties.Trace(); + assertThat(trace.isEnabled()).isTrue(); + assertThat(trace.getMaxQueueSize()).isEqualTo(2048); + assertThat(trace.getBatchSize()).isEqualTo(512); + assertThat(trace.getScheduleDelayMillis()).isEqualTo(5000); + } + + @Test + void testPromptCacheProperties() { + CozeLoopProperties.Prompt.Cache cache = new CozeLoopProperties.Prompt.Cache(); + cache.setMaxSize(500); + cache.setExpireAfterWriteMinutes(30); + cache.setRefreshAfterWriteMinutes(15); + + CozeLoopProperties.Prompt prompt = new CozeLoopProperties.Prompt(); + prompt.setCache(cache); + properties.setPrompt(prompt); + + assertThat(properties.getPrompt().getCache().getMaxSize()).isEqualTo(500); + assertThat(properties.getPrompt().getCache().getExpireAfterWriteMinutes()).isEqualTo(30); + assertThat(properties.getPrompt().getCache().getRefreshAfterWriteMinutes()).isEqualTo(15); + } + + @Test + void testPromptCacheDefaultValues() { + CozeLoopProperties.Prompt.Cache cache = new CozeLoopProperties.Prompt.Cache(); + assertThat(cache.getMaxSize()).isEqualTo(1000); + assertThat(cache.getExpireAfterWriteMinutes()).isEqualTo(60); + assertThat(cache.getRefreshAfterWriteMinutes()).isEqualTo(30); + } +} + diff --git a/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/integration/CozeLoopFullIntegrationTest.java b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/integration/CozeLoopFullIntegrationTest.java new file mode 100644 index 0000000..a13d55e --- /dev/null +++ b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/integration/CozeLoopFullIntegrationTest.java @@ -0,0 +1,135 @@ +package com.coze.loop.spring.integration; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.spring.annotation.CozeTrace; +import com.coze.loop.spring.test.OpenTelemetryTestUtils; +import com.coze.loop.trace.CozeLoopSpan; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Full integration test for CozeLoop Spring Boot Starter. + * Tests the complete flow including tracing and prompt operations. + */ +@SpringBootTest(classes = CozeLoopFullIntegrationTest.FullTestApp.class) +@TestPropertySource(properties = { + "cozeloop.workspace-id=test-workspace-full", + "cozeloop.service-name=full-test-service", + "cozeloop.auth.token=test-token-full", + "cozeloop.trace.enabled=true", + "cozeloop.http.connect-timeout-seconds=10", + "cozeloop.http.read-timeout-seconds=30", + "cozeloop.trace.max-queue-size=1024", + "cozeloop.prompt.cache.max-size=500" +}) +class CozeLoopFullIntegrationTest { + + @Autowired + private CozeLoopClient client; + + @Autowired + private FullTestService testService; + + @BeforeEach + void setUp() { + OpenTelemetryTestUtils.resetGlobalOpenTelemetry(); + } + + @Test + void testClientIsAvailable() { + assertThat(client).isNotNull(); + assertThat(client.getWorkspaceId()).isEqualTo("test-workspace-full"); + } + + @Test + void testSpanCreation() { + try (CozeLoopSpan span = client.startSpan("test-span", "custom")) { + span.setInput("test-input"); + span.setOutput("test-output"); + span.setStatusCode(0); + + assertThat(span).isNotNull(); + } + } + + @Test + void testTraceAnnotationIntegration() { + String result = testService.processWithTrace("input-data"); + + assertThat(result).isNotNull(); + assertThat(result).contains("processed"); + } + + @Test + void testTraceAnnotationWithErrorHandling() { + try { + testService.processWithError("test"); + } catch (RuntimeException e) { + assertThat(e.getMessage()).contains("test error"); + } + } + + @Test + void testTraceAnnotationWithSpel() { + String result = testService.processWithSpel("test-value", 123); + + assertThat(result).isNotNull(); + } + + @Test + void testMultipleTracedMethods() { + String result1 = testService.processWithTrace("input1"); + String result2 = testService.processWithTrace("input2"); + + assertThat(result1).isNotNull(); + assertThat(result2).isNotNull(); + } + + @SpringBootApplication + @Import(com.coze.loop.spring.autoconfigure.CozeLoopAutoConfiguration.class) + static class FullTestApp { + @Bean + public FullTestService fullTestService() { + return new FullTestService(); + } + } + + /** + * Service for full integration testing. + */ + static class FullTestService { + + @CozeTrace(value = "process-method", spanType = "custom", + captureArgs = true, captureReturn = true) + public String processWithTrace(String input) { + return "processed: " + input; + } + + @CozeTrace(value = "error-method", spanType = "custom") + public String processWithError(String input) { + throw new RuntimeException("test error: " + input); + } + + @CozeTrace(value = "#{'spel_' + #args[0] + '_' + #args[1]}", + spanType = "llm", + inputExpression = "#args[0]", + outputExpression = "#result") + public String processWithSpel(String value, int number) { + return "spel-result: " + value + "-" + number; + } + + @CozeTrace(value = "multi-arg-method", captureArgs = true) + public String processMultipleArgs(String arg1, int arg2, boolean arg3) { + return "multi: " + arg1 + "-" + arg2 + "-" + arg3; + } + } +} + diff --git a/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/integration/CozeLoopSpringBootIntegrationTest.java b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/integration/CozeLoopSpringBootIntegrationTest.java new file mode 100644 index 0000000..545b0b2 --- /dev/null +++ b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/integration/CozeLoopSpringBootIntegrationTest.java @@ -0,0 +1,105 @@ +package com.coze.loop.spring.integration; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.spring.annotation.CozeTrace; +import com.coze.loop.spring.aop.CozeTraceAspect; +import com.coze.loop.spring.config.CozeLoopProperties; +import com.coze.loop.spring.test.OpenTelemetryTestUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for CozeLoop Spring Boot Starter. + */ +@SpringBootTest(classes = CozeLoopSpringBootIntegrationTest.TestApp.class) +@TestPropertySource(properties = { + "cozeloop.workspace-id=test-workspace-integration", + "cozeloop.service-name=test-service", + "cozeloop.auth.token=test-token-123", + "cozeloop.trace.enabled=true" +}) +class CozeLoopSpringBootIntegrationTest { + + @Autowired(required = false) + private CozeLoopClient cozeLoopClient; + + @Autowired(required = false) + private CozeTraceAspect cozeTraceAspect; + + @Autowired(required = false) + private CozeLoopProperties properties; + + @Autowired(required = false) + private TestService testService; + + @BeforeEach + void setUp() { + OpenTelemetryTestUtils.resetGlobalOpenTelemetry(); + } + + @Test + void testBeansAreCreated() { + assertThat(cozeLoopClient).isNotNull(); + assertThat(cozeTraceAspect).isNotNull(); + assertThat(properties).isNotNull(); + assertThat(testService).isNotNull(); + } + + @Test + void testPropertiesAreLoaded() { + assertThat(properties.getWorkspaceId()).isEqualTo("test-workspace-integration"); + assertThat(properties.getServiceName()).isEqualTo("test-service"); + assertThat(properties.getAuth().getToken()).isEqualTo("test-token-123"); + } + + @Test + void testCozeTraceAnnotationWorks() { + String result = testService.tracedMethod("test-input"); + assertThat(result).isEqualTo("result: test-input"); + } + + @Test + void testCozeTraceWithSpelExpression() { + String result = testService.tracedMethodWithSpel("test"); + assertThat(result).isNotNull(); + } + + @SpringBootApplication + @Import(com.coze.loop.spring.autoconfigure.CozeLoopAutoConfiguration.class) + static class TestApp { + @Bean + public TestService testService() { + return new TestService(); + } + } + + /** + * Test service with @CozeTrace annotation. + */ + static class TestService { + + @CozeTrace(value = "test-method", spanType = "custom", captureArgs = true, captureReturn = true) + public String tracedMethod(String input) { + return "result: " + input; + } + + @CozeTrace(value = "#{'span_' + #args[0]}", spanType = "llm") + public String tracedMethodWithSpel(String input) { + return "spel-result: " + input; + } + + @CozeTrace(inputExpression = "#args[0]", outputExpression = "#result") + public String tracedMethodWithExpressions(String input) { + return "expression-result: " + input; + } + } +} + diff --git a/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/test/OpenTelemetryTestUtils.java b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/test/OpenTelemetryTestUtils.java new file mode 100644 index 0000000..0345fc2 --- /dev/null +++ b/cozeloop-spring-boot-starter/src/test/java/com/coze/loop/spring/test/OpenTelemetryTestUtils.java @@ -0,0 +1,35 @@ +package com.coze.loop.spring.test; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; + +import java.lang.reflect.Field; + +/** + * Utility class for OpenTelemetry testing. + */ +public class OpenTelemetryTestUtils { + + /** + * Reset the GlobalOpenTelemetry instance. + * This is useful for tests that need to create multiple OpenTelemetry instances. + */ + public static void resetGlobalOpenTelemetry() { + try { + // Try to reset using reflection + Field globalField = GlobalOpenTelemetry.class.getDeclaredField("globalOpenTelemetry"); + globalField.setAccessible(true); + globalField.set(null, null); + + Field initializedField = GlobalOpenTelemetry.class.getDeclaredField("initialized"); + initializedField.setAccessible(true); + initializedField.setBoolean(null, false); + } catch (Exception e) { + // Reflection failed - cannot reset + // This is expected in some scenarios where the internal structure has changed + // The CozeLoopTracerProvider will handle this by checking if GlobalOpenTelemetry + // is already set before trying to register globally + } + } +} + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ee50937 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,35 @@ +# 项目架构总览 + +## 模块结构 +- `cozeloop-core`:SDK 核心能力,包含客户端、认证、HTTP、Prompt、Trace、流式处理、实体与工具等。 +- `cozeloop-spring-boot-starter`:Spring Boot 自动配置与 AOP 插桩,便于落地集成。 +- `examples`:示例工程,展示认证、Prompt 使用与 Trace 上报等场景。 + +## 核心分层 +- 客户端:`CozeLoopClient`、`CozeLoopClientImpl`、`CozeLoopClientBuilder`(`cozeloop-core/src/main/java/com/coze/loop/client`)。 +- 配置:`CozeLoopConfig`(`cozeloop-core/src/main/java/com/coze/loop/config/CozeLoopConfig.java`)。 +- 认证:`Auth`、`TokenAuth`、`JWTOAuthAuth`(`cozeloop-core/src/main/java/com/coze/loop/auth`)。 +- HTTP:`HttpClient` 与拦截器 `AuthInterceptor`、`RetryInterceptor`、`LoggingInterceptor`(`cozeloop-core/src/main/java/com/coze/loop/http`)。 +- Prompt:`PromptProvider`、`PromptCache`、`TemplateEngine` 及实现 `NormalTemplateEngine`、`Jinja2TemplateEngine`、`PromptFormatter`、`VariableValidator`(`cozeloop-core/src/main/java/com/coze/loop/prompt`)。 +- Trace:`CozeLoopTracerProvider`、`CozeLoopSpan`、`CozeLoopSpanExporter`、`SpanConverter`、`FileUploader`(`cozeloop-core/src/main/java/com/coze/loop/trace`)。 +- 流式处理:`SSEDecoder`、`StreamReader`、`SSEParser`、`ServerSentEvent`(`cozeloop-core/src/main/java/com/coze/loop/stream`)。 +- 实体模型:消息与执行参数、模板、工具调用等 VO(`cozeloop-core/src/main/java/com/coze/loop/entity`)。 +- 工具:`JsonUtils`、`ValidationUtils`、`IdGenerator`(`cozeloop-core/src/main/java/com/coze/loop/internal`)。 +- 异常:`CozeLoopException` 及细分异常与错误码(`cozeloop-core/src/main/java/com/coze/loop/exception`)。 + +## Spring Boot Starter +- 自动配置:`CozeLoopAutoConfiguration`(`cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/autoconfigure/CozeLoopAutoConfiguration.java`)。 +- 配置属性:`CozeLoopProperties`(`cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/config/CozeLoopProperties.java`)。 +- AOP 插桩:注解 `CozeTrace` 与切面 `CozeTraceAspect`(`cozeloop-spring-boot-starter/src/main/java/com/coze/loop/spring/aop`)。 +- 自动导入:`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 与 `spring.factories`。 + +## 典型流程 +- 初始化客户端并注入认证拦截器,经 `HttpClient` 调用后端。 +- 从 Prompt 源获取模板,`PromptFormatter` 渲染并校验变量。 +- 通过 `CozeLoopTracerProvider` 创建 Span,经 `CozeLoopSpanExporter` 上报并可携带附件(`FileUploader`)。 +- 对于流式输出,`SSEDecoder` 解析字节流为事件,`StreamReader` 驱动 `SSEParser` 产出类型化结果。 + +## 扩展点 +- 模板引擎:实现 `TemplateEngine` 扩展渲染能力。 +- 流式解析:实现 `SSEParser` 定义事件到领域对象的映射与错误处理。 +- 认证策略:实现 `Auth` 扩充鉴权方式。 \ No newline at end of file diff --git a/docs/opentelemetry.md b/docs/opentelemetry.md new file mode 100644 index 0000000..091a8aa --- /dev/null +++ b/docs/opentelemetry.md @@ -0,0 +1,335 @@ +# OpenTelemetry Integration Guide + +## Overview + +CozeLoop Java SDK is built on top of [OpenTelemetry](https://opentelemetry.io/), a vendor-neutral observability framework. This document explains how OpenTelemetry is integrated into the SDK and how to use it effectively. + +## Why OpenTelemetry? + +OpenTelemetry provides: +- **Industry Standard**: Widely adopted observability framework +- **Vendor Neutral**: Works with any backend that supports OpenTelemetry +- **Rich Ecosystem**: Extensive instrumentation libraries +- **Automatic Batching**: Built-in batch processing for efficient export +- **Context Propagation**: Automatic trace context propagation across services +- **Mature & Battle-Tested**: Production-ready with excellent performance + +## Architecture + +The SDK uses OpenTelemetry's architecture with the following components: + +``` +┌─────────────────────────────────────────────────────────┐ +│ CozeLoop Java SDK │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ CozeLoopClient │ │ +│ │ (High-level API for users) │ │ +│ └──────────────┬───────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────▼───────────────────────────────────┐ │ +│ │ CozeLoopTracerProvider │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ +│ │ │ OpenTelemetry SDK │ │ │ +│ │ │ ┌──────────────────────────────────────┐ │ │ │ +│ │ │ │ SdkTracerProvider │ │ │ │ +│ │ │ │ ┌────────────────────────────────┐ │ │ │ │ +│ │ │ │ │ BatchSpanProcessor │ │ │ │ │ +│ │ │ │ │ (First-level batching) │ │ │ │ │ +│ │ │ │ └────────────┬───────────────────┘ │ │ │ │ +│ │ │ └───────────────┼───────────────────────┘ │ │ │ +│ │ └──────────────────┼─────────────────────────┘ │ │ +│ └─────────────────────┼──────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────▼──────────────────────────────┐ │ +│ │ CozeLoopSpanExporter │ │ +│ │ (Implements OpenTelemetry SpanExporter) │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ Second-level batching (25 spans per batch) │ │ │ +│ │ └──────────────┬───────────────────────────────┘ │ │ +│ └─────────────────┼───────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────▼───────────────────────────────────┐ │ +│ │ CozeLoop Platform │ │ +│ │ (Remote Server) │ │ +│ └──────────────────────────────────────────────────────┘ │ +``` + +## Components + +### 1. TracerProvider + +`CozeLoopTracerProvider` wraps OpenTelemetry's `SdkTracerProvider` and manages: +- **Resource**: Service metadata (service name, workspace ID) +- **Tracer**: Creates spans for instrumentation +- **SpanProcessor**: Processes and exports spans + +### 2. BatchSpanProcessor + +OpenTelemetry's `BatchSpanProcessor` provides: +- **Queue Management**: Buffers spans before export +- **Automatic Batching**: Groups spans into batches +- **Scheduled Export**: Exports on schedule or when batch is full +- **Async Processing**: Non-blocking span processing + +**Configuration Options:** +- `maxQueueSize`: Maximum number of spans in queue (default: 2048) +- `batchSize`: Maximum spans per batch (default: 512) +- `scheduleDelay`: Time between exports (default: 5000ms) +- `exportTimeout`: Timeout for export operations (default: 30000ms) + +### 3. SpanExporter + +`CozeLoopSpanExporter` implements OpenTelemetry's `SpanExporter` interface: +- Receives batches of spans from `BatchSpanProcessor` +- Converts OpenTelemetry `SpanData` to CozeLoop format using `SpanConverter` +- Handles file uploads for multimodal content (images, large text) +- Splits into smaller batches of 25 spans for efficient remote export +- Exports to CozeLoop platform via HTTP with error handling + +**Two-Level Batching:** +1. **First Level**: OpenTelemetry `BatchSpanProcessor` batches up to 512 spans (configurable) +2. **Second Level**: `CozeLoopSpanExporter` splits into batches of 25 spans for remote server + +**Error Handling:** +- Individual batch failures don't prevent other batches from being exported +- Comprehensive logging for monitoring and debugging +- Automatic retry via HTTP client retry mechanism + +### 4. Span Wrapper + +`CozeLoopSpan` wraps OpenTelemetry's `Span` and provides: +- CozeLoop-specific methods (setInput, setOutput, setModel, etc.) +- Automatic scope management (try-with-resources) +- Direct access to underlying OpenTelemetry Span +- Support for Events (addEvent) +- Support for Links (addLink) +- Full OpenTelemetry attribute support +- Error recording (setError, recordException) + +## Context Propagation + +OpenTelemetry automatically propagates trace context across: +- **Thread boundaries**: Child spans inherit parent context +- **Service boundaries**: Trace context via HTTP headers (W3C Trace Context) +- **Async operations**: Context preserved in CompletableFuture, ExecutorService + +### Example: Context Propagation + +```java +// Parent span +try (CozeLoopSpan parentSpan = client.startSpan("parent", "custom")) { + parentSpan.setAttribute("user_id", "12345"); + + // Child span automatically inherits parent context + try (CozeLoopSpan childSpan = client.startSpan("child", "custom")) { + // This span is automatically linked to parent + childSpan.setInput("child operation"); + } + + // Async operation with context propagation + CompletableFuture.runAsync(() -> { + // Context is automatically propagated + try (CozeLoopSpan asyncSpan = client.startSpan("async", "custom")) { + // This span is also a child of parent + } + }); +} +``` + +## Using OpenTelemetry APIs Directly + +You can also use OpenTelemetry APIs directly: + +```java +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Scope; + +// Get the underlying OpenTelemetry Tracer +Tracer tracer = client.getTracer(); + +// Create span using OpenTelemetry API +Span span = tracer.spanBuilder("my-operation") + .setAttribute("custom.key", "value") + .startSpan(); + +try (Scope scope = span.makeCurrent()) { + // Your code here + span.addEvent("event-name"); + span.setStatus(StatusCode.OK); +} finally { + span.end(); +} +``` + +## Span Lifecycle + +1. **Start**: Span is created and made current in context +2. **Active**: Span is in context, child spans inherit it +3. **End**: Span is finished and sent to processor +4. **Processed**: BatchSpanProcessor batches the span +5. **Exported**: CozeLoopSpanExporter converts and sends to platform + +## Advanced Features + +### Events + +Events are timestamped annotations on a span that represent something that happened during the span's lifetime: + +```java +try (CozeLoopSpan span = client.startSpan("operation", "custom")) { + span.addEvent("operation-started"); + // ... do work ... + span.addEvent("operation-completed"); +} +``` + +### Links + +Links connect spans to other spans, typically used to represent causal relationships: + +```java +// Get span context from another trace +SpanContext linkedSpanContext = ...; + +try (CozeLoopSpan span = client.startSpan("operation", "custom")) { + span.addLink(linkedSpanContext); + // ... do work ... +} +``` + +### Baggage + +Baggage is key-value data that is propagated across service boundaries. It's useful for passing contextual information: + +```java +import io.opentelemetry.api.baggage.Baggage; +import io.opentelemetry.context.Context; + +// Set baggage in current context +Baggage baggage = Baggage.builder() + .put("user_id", "12345") + .put("request_id", "req-abc") + .build(); + +try (Scope scope = baggage.storeInContext(Context.current()).makeCurrent()) { + // All spans created in this scope will have access to baggage + try (CozeLoopSpan span = client.startSpan("operation", "custom")) { + // Baggage is automatically propagated + } +} +``` + +## Resource Attributes + +The SDK automatically sets these resource attributes: +- `service.name`: Service name (from configuration) +- `workspace.id`: CozeLoop workspace ID + +You can add custom resource attributes: + +```java +Resource customResource = Resource.builder() + .put(AttributeKey.stringKey("deployment.environment"), "production") + .put(AttributeKey.stringKey("service.version"), "1.0.0") + .build(); +``` + +## Best Practices + +### 1. Use Try-With-Resources + +Always use try-with-resources to ensure spans are properly closed: + +```java +try (CozeLoopSpan span = client.startSpan("operation", "custom")) { + // Your code +} +``` + +### 2. Set Attributes Early + +Set important attributes as early as possible: + +```java +try (CozeLoopSpan span = client.startSpan("llm-call", "llm")) { + span.setModelProvider("openai"); + span.setModel("gpt-4"); + // Then make the actual call +} +``` + +### 3. Handle Errors Properly + +Always set error status when exceptions occur: + +```java +try (CozeLoopSpan span = client.startSpan("operation", "custom")) { + // Your code +} catch (Exception e) { + span.setError(e); + span.setStatusCode(1); + throw e; +} +``` + +### 4. Use Appropriate Span Types + +Use semantic span types: +- `"llm"`: For LLM API calls +- `"tool"`: For tool/function calls +- `"custom"`: For custom operations + +### 5. Batch Configuration + +Tune batch settings based on your workload: + +```java +TraceConfig config = TraceConfig.builder() + .maxQueueSize(4096) // Larger queue for high throughput + .batchSize(1024) // Larger batches + .scheduleDelayMillis(1000) // More frequent exports + .exportTimeoutMillis(60000) // Longer timeout + .build(); +``` + +## Integration with Other OpenTelemetry Instrumentation + +The SDK can work alongside other OpenTelemetry instrumentation: + +```java +// Your application might have other OpenTelemetry instrumentation +// CozeLoop SDK will use the same TracerProvider if already initialized +// Otherwise, it will create and register its own + +// Both will work together seamlessly +``` + +## Troubleshooting + +### Spans Not Appearing + +1. **Check client is not closed**: Ensure `client.close()` is called only at shutdown +2. **Check batch delay**: Spans may be queued, wait for batch export +3. **Check logs**: Look for export errors in logs +4. **Force flush**: Call `tracerProvider.shutdown()` to flush pending spans + +### Performance Issues + +1. **Reduce batch size**: Smaller batches = more frequent exports +2. **Increase queue size**: Prevents span drops under load +3. **Adjust schedule delay**: Balance between latency and throughput + +### Context Not Propagating + +1. **Ensure span is current**: Use try-with-resources or `span.makeCurrent()` +2. **Check thread boundaries**: Context propagates automatically within threads +3. **For async**: Use OpenTelemetry's context propagation utilities + +## References + +- [OpenTelemetry Java Documentation](https://opentelemetry.io/docs/instrumentation/java/) +- [OpenTelemetry Specification](https://opentelemetry.io/docs/specs/otel/) +- [W3C Trace Context](https://www.w3.org/TR/trace-context/) + diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..1ecc6c1 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,35 @@ +# 测试策略与运行指南 + +## 测试栈 +- 单元测试:JUnit 5(`org.junit.jupiter:junit-jupiter`)。 +- 断言:AssertJ(`org.assertj:assertj-core`)。 +- Mock:Mockito(`org.mockito:mockito-core`、`mockito-junit-jupiter`)。 +- Spring 测试:`spring-boot-starter-test` 覆盖 `Starter` 模块的自动配置与 AOP。 +- HTTP 测试:OkHttp `MockWebServer` 用于服务端模拟。 + +## 目录结构 +- 核心模块:`cozeloop-core/src/test/java` 按功能包组织(client、http、prompt、internal、auth 等)。 +- Starter 模块:`cozeloop-spring-boot-starter/src/test/java` 覆盖自动配置、属性绑定、AOP 与集成路径。 + +## 运行方式 +- 全量:在项目根执行 `mvn -q test`。 +- 指定模块:`mvn -q -pl cozeloop-core -am test` 或 `-pl cozeloop-spring-boot-starter -am test`。 + +## 范围与覆盖 +- 客户端生命周期与配置装配。 +- HTTP 拦截器与重试、日志、鉴权。 +- Prompt 渲染、缓存、校验与模板引擎实现。 +- Trace 生成、转换与导出逻辑。 +- 流式解析:事件解码与结果提取(新增示例见 `stream` 相关测试)。 +- Starter 自动配置、AOP 与跨模块集成测试。 + +## 编写规范 +- 命名:`ClassNameTest`,同包位于 `src/test/java`。 +- 结构:Given/When/Then,断言使用 AssertJ,Mock 使用 Mockito。 +- 隔离:外部交互通过 Mock 或 `MockWebServer`,避免网络依赖。 +- 可读性:一测一责,避免过度耦合;必要时提取测试工具类。 + +## 示例与最佳实践 +- HTTP 客户端:使用 `MockWebServer` 验证拦截器与重试策略。 +- OpenTelemetry:`OpenTelemetryTestUtils` 提供采集与断言工具(Starter 模块)。 +- 流式处理:构造 SSE 文本流验证 `SSEDecoder` 与 `StreamReader` 的事件切分、错误跳过与关闭语义。 \ No newline at end of file diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..9fb4624 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,251 @@ +# CozeLoop Java SDK 示例代码 + +本目录包含了 CozeLoop Java SDK 的完整示例代码,帮助开发者快速上手使用 SDK。 + +## 目录结构 + +``` +examples/ +├── init/ # 初始化示例 +│ ├── pat/ # Personal Access Token 初始化 +│ ├── oauth_jwt/ # OAuth JWT 认证初始化 +│ └── error/ # 错误处理示例 +├── trace/ # 追踪示例 +│ ├── simple/ # 简单追踪示例 +│ ├── parent_child/ # 父子 span 示例 +│ └── prompt/ # 追踪与提示结合示例 +└── prompt/ # 提示管理示例 + ├── prompt_hub/ # Prompt Hub 基础示例 + └── prompt_hub_jinja/ # Jinja 模板示例 +``` + +## 环境变量配置 + +在运行示例之前,请先设置以下环境变量: + +### 使用 PAT (Personal Access Token) 认证 + +```bash +export COZELOOP_WORKSPACE_ID=your_workspace_id +export COZELOOP_API_TOKEN=your_token +``` + +**注意**:PAT 仅用于测试环境,生产环境请使用 OAuth JWT 认证。 + +### 使用 OAuth JWT 认证(推荐) + +```bash +export COZELOOP_WORKSPACE_ID=your_workspace_id +export COZELOOP_JWT_OAUTH_CLIENT_ID=your_client_id +export COZELOOP_JWT_OAUTH_PRIVATE_KEY=your_private_key +export COZELOOP_JWT_OAUTH_PUBLIC_KEY_ID=your_public_key_id +``` + +## 快速开始 + +### 1. 构建项目 + +首先需要构建父项目: + +```bash +cd /Users/jiafan/Desktop/poc/cozeloop-java +mvn clean install +``` + +### 2. 运行示例 + +#### 方式一:使用 Maven Exec 插件 + +```bash +cd examples +mvn exec:java -Dexec.mainClass="init.pat.PatExample" +``` + +#### 方式二:直接运行 Java 类 + +```bash +cd examples +# 编译 +mvn compile + +# 运行(需要设置 classpath) +java -cp target/classes:../cozeloop-core/target/cozeloop-core-1.0.0-SNAPSHOT.jar:... init.pat.PatExample +``` + +## 示例说明 + +### 初始化示例 (init/) + +#### PAT 初始化 (`init/pat/PatExample.java`) + +展示如何使用 Personal Access Token 初始化客户端。 + +**运行方式:** +```bash +mvn exec:java -Dexec.mainClass="init.pat.PatExample" +``` + +**要点:** +- PAT 仅用于测试环境 +- 生产环境应使用 OAuth JWT +- 展示基本用法和自定义配置用法 + +#### OAuth JWT 初始化 (`init/oauth_jwt/OAuthJwtExample.java`) + +展示如何使用 OAuth JWT 认证初始化客户端(生产环境推荐)。 + +**运行方式:** +```bash +mvn exec:java -Dexec.mainClass="init.oauth_jwt.OAuthJwtExample" +``` + +**要点:** +- 生产环境推荐的认证方式 +- 支持环境变量和代码配置两种方式 + +#### 错误处理 (`init/error/ErrorHandlingExample.java`) + +展示如何正确处理异常和错误。 + +**运行方式:** +```bash +mvn exec:java -Dexec.mainClass="init.error.ErrorHandlingExample" +``` + +**要点:** +- 客户端初始化错误处理 +- Span 操作错误处理 +- 业务逻辑错误处理 + +### 追踪示例 (trace/) + +#### 简单追踪 (`trace/simple/SimpleTraceExample.java`) + +展示基本的 span 创建和使用。 + +**运行方式:** +```bash +mvn exec:java -Dexec.mainClass="trace.simple.SimpleTraceExample" +``` + +**要点:** +- 创建 span +- 设置 input/output +- 设置 model 信息 +- 设置 tokens 信息 +- 完整的 LLM 调用追踪流程 + +#### 父子 Span (`trace/parent_child/ParentChildSpanExample.java`) + +展示如何创建父子关系的 span。 + +**运行方式:** +```bash +mvn exec:java -Dexec.mainClass="trace.parent_child.ParentChildSpanExample" +``` + +**要点:** +- 创建父子关系的 span +- Context 传递机制 +- 异步任务的追踪 + +#### 追踪与提示结合 (`trace/prompt/TraceWithPromptExample.java`) + +展示在追踪过程中使用 prompt。 + +**运行方式:** +```bash +mvn exec:java -Dexec.mainClass="trace.prompt.TraceWithPromptExample" +``` + +**要点:** +- 在追踪过程中获取 prompt +- 格式化 prompt +- 与 LLM 调用的完整流程 + +**前置条件:** +- 需要在平台上创建一个 Prompt(Prompt Key: `prompt_hub_demo`) + +### 提示管理示例 (prompt/) + +#### Prompt Hub 基础 (`prompt/prompt_hub/PromptHubExample.java`) + +展示如何获取和格式化 prompt。 + +**运行方式:** +```bash +mvn exec:java -Dexec.mainClass="prompt.prompt_hub.PromptHubExample" +``` + +**要点:** +- 获取 prompt +- 格式化 prompt(普通变量和 placeholder 变量) +- 与 LLM 调用的集成 + +**前置条件:** +- 需要在平台上创建一个 Prompt(Prompt Key: `prompt_hub_demo`) +- Prompt 模板应包含: + - System: You are a helpful bot, the conversation topic is {{var1}}. + - Placeholder: placeholder1 + - User: My question is {{var2}}. + - Placeholder: placeholder2 + +#### Jinja 模板 (`prompt/prompt_hub_jinja/PromptHubJinjaExample.java`) + +展示 Jinja 模板的使用和各种变量类型。 + +**运行方式:** +```bash +mvn exec:java -Dexec.mainClass="prompt.prompt_hub_jinja.PromptHubJinjaExample" +``` + +**要点:** +- Jinja 模板的使用 +- 各种变量类型的格式化(string, int, bool, float, object, array) +- 复杂数据结构的处理 + +**前置条件:** +- 需要在平台上创建一个 Prompt(Prompt Key: `prompt_hub_demo`) + +## 创建 Prompt + +在运行 prompt 相关示例之前,需要在 CozeLoop 平台上创建相应的 Prompt: + +1. 访问 CozeLoop 平台的 Prompt 开发页面 +2. 创建新的 Prompt,设置 Prompt Key 为 `prompt_hub_demo` +3. 在模板中添加消息(参考各示例的注释说明) +4. 提交版本 + +## 常见问题 + +### 1. 如何获取 Workspace ID 和 Token? + +- **Workspace ID**: 在 CozeLoop 平台的工作空间设置中查看 +- **PAT Token**: 访问 https://www.coze.cn/open/oauth/pat 创建 +- **OAuth JWT**: 访问 https://www.coze.cn/open/oauth/apps 创建应用 + +### 2. 示例运行失败怎么办? + +- 检查环境变量是否正确设置 +- 检查网络连接是否正常 +- 检查 Workspace ID 和 Token 是否正确 +- 查看错误日志获取详细信息 + +### 3. 如何自定义配置? + +参考各示例中的 `useCustomClient()` 方法,展示如何设置自定义配置。 + +### 4. 为什么需要关闭客户端? + +客户端关闭时会自动刷新并上报所有待上报的 traces。如果不关闭客户端,可能会丢失未上报的 traces。 + +## 更多资源 + +- [CozeLoop 官方文档](https://loop.coze.cn/open/docs) +- [Java SDK API 文档](../README.md) +- [Python SDK 示例](https://loop.coze.cn/open/docs/cozeloop/python-sdk) + +## 许可证 + +本示例代码遵循与主项目相同的 MIT 许可证。 + diff --git a/examples/pom.xml b/examples/pom.xml new file mode 100644 index 0000000..cc0fd8a --- /dev/null +++ b/examples/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + com.coze.loop + cozeloop-java-examples + 1.0.0-SNAPSHOT + jar + + CozeLoop Java SDK Examples + Example code for CozeLoop Java SDK + + + com.coze.loop + cozeloop-java-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + + + + com.coze.loop + cozeloop-core + ${project.version} + + + + + org.slf4j + slf4j-simple + ${slf4j.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + ${project.build.sourceEncoding} + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + ${exec.mainClass} + + + + + diff --git a/examples/src/main/java/init/error/ErrorHandlingExample.java b/examples/src/main/java/init/error/ErrorHandlingExample.java new file mode 100644 index 0000000..580ffd9 --- /dev/null +++ b/examples/src/main/java/init/error/ErrorHandlingExample.java @@ -0,0 +1,155 @@ +package init.error; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.client.CozeLoopClientBuilder; +import com.coze.loop.exception.CozeLoopException; +import com.coze.loop.trace.CozeLoopSpan; + +/** + * 错误处理示例 + * + * 展示如何正确处理 CozeLoop SDK 中的异常和错误。 + * + * 使用前请先设置以下环境变量: + * - COZELOOP_WORKSPACE_ID: 你的工作空间 ID + * - COZELOOP_API_TOKEN: 你的访问令牌(或使用 OAuth JWT) + */ +public class ErrorHandlingExample { + + public static void main(String[] args) { + String workspaceId = System.getenv("COZELOOP_WORKSPACE_ID"); + String apiToken = System.getenv("COZELOOP_API_TOKEN"); + + if (workspaceId == null || apiToken == null) { + System.err.println("请设置环境变量:"); + System.err.println(" COZELOOP_WORKSPACE_ID=your_workspace_id"); + System.err.println(" COZELOOP_API_TOKEN=your_token"); + System.exit(1); + } + + // 示例1:客户端初始化错误处理 + handleClientInitializationError(workspaceId, apiToken); + + // 示例2:Span 操作错误处理 + handleSpanOperationError(workspaceId, apiToken); + + // 示例3:业务逻辑错误处理 + handleBusinessLogicError(workspaceId, apiToken); + } + + /** + * 示例1:客户端初始化错误处理 + */ + private static void handleClientInitializationError(String workspaceId, String apiToken) { + try { + // 如果配置错误,build() 会抛出 CozeLoopException + CozeLoopClient client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .tokenAuth(apiToken) + .build(); + + System.out.println("客户端初始化成功"); + client.close(); + } catch (CozeLoopException e) { + System.err.println("客户端初始化失败:" + e.getMessage()); + System.err.println("错误代码:" + e.getErrorCode()); + e.printStackTrace(); + } catch (Exception e) { + System.err.println("未知错误:" + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * 示例2:Span 操作错误处理 + */ + private static void handleSpanOperationError(String workspaceId, String apiToken) { + CozeLoopClient client = null; + try { + client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .tokenAuth(apiToken) + .build(); + + // 正常使用 span + try (CozeLoopSpan span = client.startSpan("test_span", "custom")) { + span.setInput("test input"); + span.setOutput("test output"); + // 如果操作失败,可以设置错误状态 + // span.setError(new RuntimeException("业务错误")); + } + + System.out.println("Span 操作成功"); + } catch (CozeLoopException e) { + System.err.println("CozeLoop 操作失败:" + e.getMessage()); + System.err.println("错误代码:" + e.getErrorCode()); + } catch (Exception e) { + System.err.println("未知错误:" + e.getMessage()); + e.printStackTrace(); + } finally { + if (client != null) { + try { + client.close(); + } catch (Exception e) { + System.err.println("关闭客户端时出错:" + e.getMessage()); + } + } + } + } + + /** + * 示例3:业务逻辑错误处理 + */ + private static void handleBusinessLogicError(String workspaceId, String apiToken) { + CozeLoopClient client = null; + try { + client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .tokenAuth(apiToken) + .build(); + + // 模拟业务逻辑 + try (CozeLoopSpan span = client.startSpan("business_operation", "custom")) { + span.setInput("开始业务操作"); + + // 模拟可能失败的业务逻辑 + boolean success = performBusinessLogic(); + + if (success) { + span.setOutput("业务操作成功"); + span.setStatusCode(0); // 0 表示成功 + System.out.println("业务操作成功"); + } else { + // 业务失败时,设置错误信息 + RuntimeException error = new RuntimeException("业务逻辑执行失败"); + span.setError(error); + span.setStatusCode(1); // 非0 表示失败 + System.err.println("业务操作失败"); + } + } + } catch (CozeLoopException e) { + System.err.println("CozeLoop SDK 错误:" + e.getMessage()); + System.err.println("错误代码:" + e.getErrorCode()); + } catch (Exception e) { + System.err.println("业务异常:" + e.getMessage()); + e.printStackTrace(); + } finally { + if (client != null) { + try { + client.close(); + } catch (Exception e) { + System.err.println("关闭客户端时出错:" + e.getMessage()); + } + } + } + } + + /** + * 模拟业务逻辑(可能成功或失败) + */ + private static boolean performBusinessLogic() { + // 模拟业务逻辑,这里随机返回成功或失败 + return Math.random() > 0.3; // 70% 成功率 + } +} + diff --git a/examples/src/main/java/init/oauth_jwt/OAuthJwtExample.java b/examples/src/main/java/init/oauth_jwt/OAuthJwtExample.java new file mode 100644 index 0000000..8169b34 --- /dev/null +++ b/examples/src/main/java/init/oauth_jwt/OAuthJwtExample.java @@ -0,0 +1,102 @@ +package init.oauth_jwt; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.client.CozeLoopClientBuilder; +import com.coze.loop.trace.CozeLoopSpan; + +/** + * OAuth JWT 认证初始化示例 + * + * 这是生产环境推荐的认证方式,比 PAT 更安全。 + * + * 使用前请先设置以下环境变量: + * - COZELOOP_WORKSPACE_ID: 你的工作空间 ID + * - COZELOOP_JWT_OAUTH_CLIENT_ID: 你的客户端 ID + * - COZELOOP_JWT_OAUTH_PRIVATE_KEY: 你的私钥(PEM 格式) + * - COZELOOP_JWT_OAUTH_PUBLIC_KEY_ID: 你的公钥 ID + * + * 创建应用的步骤: + * 1. 访问 https://www.coze.cn/open/oauth/apps + * 2. 创建新的应用 + * 3. 妥善保管你的 publicKeyID 和 privateKey,防止数据泄露 + */ +public class OAuthJwtExample { + + public static void main(String[] args) { + // 从环境变量获取配置 + String workspaceId = System.getenv("COZELOOP_WORKSPACE_ID"); + String clientId = System.getenv("COZELOOP_JWT_OAUTH_CLIENT_ID"); + String privateKey = System.getenv("COZELOOP_JWT_OAUTH_PRIVATE_KEY"); + String publicKeyId = System.getenv("COZELOOP_JWT_OAUTH_PUBLIC_KEY_ID"); + + if (workspaceId == null || clientId == null || privateKey == null || publicKeyId == null) { + System.err.println("请设置环境变量:"); + System.err.println(" COZELOOP_WORKSPACE_ID=your_workspace_id"); + System.err.println(" COZELOOP_JWT_OAUTH_CLIENT_ID=your_client_id"); + System.err.println(" COZELOOP_JWT_OAUTH_PRIVATE_KEY=your_private_key"); + System.err.println(" COZELOOP_JWT_OAUTH_PUBLIC_KEY_ID=your_public_key_id"); + System.exit(1); + } + + // 方式1:使用默认配置创建客户端(最简单的方式) + useDefaultClient(workspaceId, clientId, privateKey, publicKeyId); + + // 方式2:使用自定义配置创建客户端 + // useCustomClient(workspaceId, clientId, privateKey, publicKeyId); + } + + /** + * 使用默认配置创建客户端 + */ + private static void useDefaultClient(String workspaceId, String clientId, + String privateKey, String publicKeyId) { + // 创建客户端 + CozeLoopClient client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .jwtOAuth(clientId, privateKey, publicKeyId) + .build(); + + try { + // 使用客户端创建 span + try (CozeLoopSpan span = client.startSpan("first_span", "custom")) { + span.setAttribute("example", "oauth_jwt_init"); + System.out.println("使用 OAuth JWT 创建了第一个 span"); + } + + System.out.println("示例执行成功!"); + } finally { + // 重要:程序退出前记得关闭客户端,否则可能丢失未上报的 traces + client.close(); + } + } + + /** + * 使用自定义配置创建客户端 + */ + private static void useCustomClient(String workspaceId, String clientId, + String privateKey, String publicKeyId) { + // 创建带自定义配置的客户端 + CozeLoopClient client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .jwtOAuth(clientId, privateKey, publicKeyId) + // 可以设置自定义的 base URL(一般不需要) + // .baseUrl("https://api.coze.cn") + // 可以设置服务名称 + .serviceName("my-production-service") + .build(); + + try { + // 使用客户端 + try (CozeLoopSpan span = client.startSpan("custom_span", "custom")) { + span.setAttribute("example", "oauth_jwt_custom_config"); + System.out.println("使用自定义配置创建了 span"); + } + + System.out.println("自定义配置示例执行成功!"); + } finally { + // 重要:程序退出前记得关闭客户端 + client.close(); + } + } +} + diff --git a/examples/src/main/java/init/pat/PatExample.java b/examples/src/main/java/init/pat/PatExample.java new file mode 100644 index 0000000..b48597a --- /dev/null +++ b/examples/src/main/java/init/pat/PatExample.java @@ -0,0 +1,95 @@ +package init.pat; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.client.CozeLoopClientBuilder; +import com.coze.loop.trace.CozeLoopSpan; + +/** + * Personal Access Token (PAT) 初始化示例 + * + * 重要提示:Personal Access Token 不够安全,仅用于测试环境。 + * 生产环境请使用 OAuth JWT 认证方式。 + * + * 使用前请先设置以下环境变量: + * - COZELOOP_WORKSPACE_ID: 你的工作空间 ID + * - COZELOOP_API_TOKEN: 你的访问令牌 + * + * 创建令牌的步骤: + * 1. 访问 https://www.coze.cn/open/oauth/pat + * 2. 创建新的令牌 + * 3. 妥善保管你的令牌,防止数据泄露 + */ +public class PatExample { + + public static void main(String[] args) { + // 从环境变量获取配置 + String workspaceId = System.getenv("COZELOOP_WORKSPACE_ID"); + String apiToken = System.getenv("COZELOOP_API_TOKEN"); + + if (workspaceId == null || apiToken == null) { + System.err.println("请设置环境变量:"); + System.err.println(" COZELOOP_WORKSPACE_ID=your_workspace_id"); + System.err.println(" COZELOOP_API_TOKEN=your_token"); + System.exit(1); + } + + // 方式1:使用默认配置创建客户端(最简单的方式) + useDefaultClient(workspaceId, apiToken); + + // 方式2:使用自定义配置创建客户端 + // useCustomClient(workspaceId, apiToken); + } + + /** + * 使用默认配置创建客户端 + */ + private static void useDefaultClient(String workspaceId, String apiToken) { + // 创建客户端 + CozeLoopClient client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .tokenAuth(apiToken) + .build(); + + try { + // 使用客户端创建 span + try (CozeLoopSpan span = client.startSpan("first_span", "custom")) { + span.setAttribute("example", "pat_init"); + System.out.println("使用 PAT 创建了第一个 span"); + } + + System.out.println("示例执行成功!"); + } finally { + // 重要:程序退出前记得关闭客户端,否则可能丢失未上报的 traces + client.close(); + } + } + + /** + * 使用自定义配置创建客户端 + */ + private static void useCustomClient(String workspaceId, String apiToken) { + // 创建带自定义配置的客户端 + CozeLoopClient client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .tokenAuth(apiToken) + // 可以设置自定义的 base URL(一般不需要) + // .baseUrl("https://api.coze.cn") + // 可以设置服务名称 + .serviceName("my-custom-service") + .build(); + + try { + // 使用客户端 + try (CozeLoopSpan span = client.startSpan("custom_span", "custom")) { + span.setAttribute("example", "pat_custom_config"); + System.out.println("使用自定义配置创建了 span"); + } + + System.out.println("自定义配置示例执行成功!"); + } finally { + // 重要:程序退出前记得关闭客户端 + client.close(); + } + } +} + diff --git a/examples/src/main/java/prompt/prompt_hub/PromptHubExample.java b/examples/src/main/java/prompt/prompt_hub/PromptHubExample.java new file mode 100644 index 0000000..05fba60 --- /dev/null +++ b/examples/src/main/java/prompt/prompt_hub/PromptHubExample.java @@ -0,0 +1,168 @@ +package prompt.prompt_hub; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.client.CozeLoopClientBuilder; +import com.coze.loop.entity.Message; +import com.coze.loop.entity.Prompt; +import com.coze.loop.entity.Role; +import com.coze.loop.prompt.GetPromptParam; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Prompt Hub 基础示例 + * + * 展示如何: + * - 获取 prompt + * - 格式化 prompt(包含普通变量和 placeholder 变量) + * - 与 LLM 调用的集成 + * + * 使用前请先设置以下环境变量: + * - COZELOOP_WORKSPACE_ID: 你的工作空间 ID + * - COZELOOP_API_TOKEN: 你的访问令牌 + * + * 注意:需要在平台上创建一个 Prompt(Prompt Key 设置为 'prompt_hub_demo'), + * 并在模板中添加以下消息,然后提交版本: + * - System: You are a helpful bot, the conversation topic is {{var1}}. + * - Placeholder: placeholder1 + * - User: My question is {{var2}} + * - Placeholder: placeholder2 + */ +public class PromptHubExample { + + public static void main(String[] args) { + String workspaceId = System.getenv("COZELOOP_WORKSPACE_ID"); + String apiToken = System.getenv("COZELOOP_API_TOKEN"); + + if (workspaceId == null || apiToken == null) { + System.err.println("请设置环境变量:"); + System.err.println(" COZELOOP_WORKSPACE_ID=your_workspace_id"); + System.err.println(" COZELOOP_API_TOKEN=your_token"); + System.exit(1); + } + + CozeLoopClient client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .tokenAuth(apiToken) + .build(); + + try { + // 1. 创建根 span + try (com.coze.loop.trace.CozeLoopSpan rootSpan = + client.startSpan("root_span", "main_span")) { + + // 2. 获取 prompt + Prompt prompt = client.getPrompt(GetPromptParam.builder() + .promptKey("prompt_hub_demo") + .version("0.0.1") // 如果不指定版本,将获取对应 prompt 的最新版本 + .build()); + + if (prompt == null) { + System.err.println("获取 prompt 失败:prompt 不存在"); + return; + } + + // 3. 获取 prompt 的消息 + if (prompt.getPromptTemplate() != null && + prompt.getPromptTemplate().getMessages() != null) { + System.out.println("Prompt 消息:"); + for (Message msg : prompt.getPromptTemplate().getMessages()) { + System.out.println(" Role: " + msg.getRole() + + ", Content: " + msg.getContent()); + } + } + + // 4. 获取 prompt 的 LLM 配置 + if (prompt.getLlmConfig() != null) { + System.out.println("Prompt LLM 配置: " + prompt.getLlmConfig()); + } + + // 5. 格式化 prompt 消息 + String userMessageContent = "Hello!"; + String assistantMessageContent = "Hello!"; + + // 准备变量 + Map variables = new HashMap<>(); + // 普通变量类型应该是 String + variables.put("var1", "artificial intelligence"); + variables.put("var2", "What is AI?"); + + // Placeholder 变量类型应该是 Message/List + // 注意:prompt 模板中未提供对应值的变量将被视为空值 + List placeholder1 = new ArrayList<>(); + placeholder1.add(Message.builder() + .role(Role.USER) + .content(userMessageContent) + .build()); + placeholder1.add(Message.builder() + .role(Role.ASSISTANT) + .content(assistantMessageContent) + .build()); + variables.put("placeholder1", placeholder1); + + // 格式化 prompt + List messages = client.formatPrompt(prompt, variables); + + System.out.println("\n格式化后的消息:"); + for (Message msg : messages) { + System.out.println(" Role: " + msg.getRole() + + ", Content: " + msg.getContent()); + } + + // 6. 调用 LLM + callLLM(client, messages); + } + + System.out.println("\nPrompt Hub 示例执行成功!"); + } catch (Exception e) { + System.err.println("执行失败:" + e.getMessage()); + e.printStackTrace(); + } finally { + // 7. 关闭客户端 + client.close(); + } + } + + /** + * 调用 LLM + */ + private static void callLLM(CozeLoopClient client, List messages) { + try (com.coze.loop.trace.CozeLoopSpan span = + client.startSpan("llmCall", "llm")) { + // 模拟 LLM 处理 + String modelName = "gpt-4o-2024-05-13"; + + try { + Thread.sleep(1000); // 模拟网络延迟 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + // 模拟响应 + String[] respChoices = {"Hello! Can I help you?"}; + int respPromptTokens = 11; + int respCompletionTokens = 52; + + // 设置 span 属性 + span.setInput(messages); + span.setOutput(respChoices); + span.setModelProvider("openai"); + span.setModel(modelName); + span.setInputTokens(respPromptTokens); + span.setOutputTokens(respCompletionTokens); + + // 设置首次响应时间 + long firstRespTime = System.currentTimeMillis() * 1000; + span.setAttribute("start_time_first_resp", firstRespTime); + + System.out.println("\nLLM 调用完成"); + System.out.println(" 输出: " + respChoices[0]); + System.out.println(" 模型: " + modelName); + } + } +} + diff --git a/examples/src/main/java/prompt/prompt_hub_jinja/PromptHubJinjaExample.java b/examples/src/main/java/prompt/prompt_hub_jinja/PromptHubJinjaExample.java new file mode 100644 index 0000000..7cbeeaf --- /dev/null +++ b/examples/src/main/java/prompt/prompt_hub_jinja/PromptHubJinjaExample.java @@ -0,0 +1,216 @@ +package prompt.prompt_hub_jinja; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.client.CozeLoopClientBuilder; +import com.coze.loop.entity.Message; +import com.coze.loop.entity.Prompt; +import com.coze.loop.entity.Role; +import com.coze.loop.prompt.GetPromptParam; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Prompt Hub Jinja 模板示例 + * + * 如果你想在 prompt 中使用 Jinja 模板,可以参考以下示例。 + * + * 展示: + * - Jinja 模板的使用 + * - 各种变量类型的格式化(string, int, bool, float, object, array) + * - 复杂数据结构的处理 + * + * 使用前请先设置以下环境变量: + * - COZELOOP_WORKSPACE_ID: 你的工作空间 ID + * - COZELOOP_API_TOKEN: 你的访问令牌 + * + * 注意:需要在平台上创建一个 Prompt(Prompt Key 设置为 'prompt_hub_demo'), + * 并在模板中添加以下消息,然后提交版本: + * - System: You are a helpful bot, the conversation topic is {{var1}}. + * - Placeholder: placeholder1 + * - User: My question is {{var2}} + * - Placeholder: placeholder2 + */ +public class PromptHubJinjaExample { + + public static void main(String[] args) { + String workspaceId = System.getenv("COZELOOP_WORKSPACE_ID"); + String apiToken = System.getenv("COZELOOP_API_TOKEN"); + + if (workspaceId == null || apiToken == null) { + System.err.println("请设置环境变量:"); + System.err.println(" COZELOOP_WORKSPACE_ID=your_workspace_id"); + System.err.println(" COZELOOP_API_TOKEN=your_token"); + System.exit(1); + } + + CozeLoopClient client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .tokenAuth(apiToken) + .build(); + + try { + // 1. 创建根 span + try (com.coze.loop.trace.CozeLoopSpan rootSpan = + client.startSpan("root_span", "main_span")) { + + // 2. 获取 prompt + Prompt prompt = client.getPrompt(GetPromptParam.builder() + .promptKey("prompt_hub_demo") + .version("0.0.1") // 如果不指定版本,将获取对应 prompt 的最新版本 + .build()); + + if (prompt == null) { + System.err.println("获取 prompt 失败:prompt 不存在"); + return; + } + + // 3. 准备各种类型的变量(用于 Jinja 模板) + Map variables = new HashMap<>(); + + // 字符串变量 + variables.put("var_string", "hi"); + + // 整数变量 + variables.put("var_int", 5); + + // 布尔变量 + variables.put("var_bool", true); + + // 浮点数变量 + variables.put("var_float", 1.0); + + // 对象变量 + Map address = new HashMap<>(); + address.put("city", "beijing"); + address.put("street", "123 Main"); + + List hobbies = new ArrayList<>(); + hobbies.add("reading"); + hobbies.add("coding"); + + Map person = new HashMap<>(); + person.put("name", "John"); + person.put("age", 30); + person.put("hobbies", hobbies); + person.put("address", address); + variables.put("var_object", person); + + // 字符串数组 + List stringArray = new ArrayList<>(); + stringArray.add("hello"); + stringArray.add("nihao"); + variables.put("var_array_string", stringArray); + + // 布尔数组 + List boolArray = new ArrayList<>(); + boolArray.add(true); + boolArray.add(false); + boolArray.add(true); + variables.put("var_array_boolean", boolArray); + + // 整数数组 + List intArray = new ArrayList<>(); + intArray.add(1L); + intArray.add(2L); + intArray.add(3L); + intArray.add(4L); + variables.put("var_array_int", intArray); + + // 浮点数数组 + List floatArray = new ArrayList<>(); + floatArray.add(1.0); + floatArray.add(2.0); + variables.put("var_array_float", floatArray); + + // 对象数组 + List> objectArray = new ArrayList<>(); + Map obj1 = new HashMap<>(); + obj1.put("key", "123"); + Map obj2 = new HashMap<>(); + obj2.put("value", 100); + objectArray.add(obj1); + objectArray.add(obj2); + variables.put("var_array_object", objectArray); + + // Placeholder 变量类型应该是 Message/List + String userMessageContent = "Hello!"; + String assistantMessageContent = "Hello!"; + List placeholder1 = new ArrayList<>(); + placeholder1.add(Message.builder() + .role(Role.USER) + .content(userMessageContent) + .build()); + placeholder1.add(Message.builder() + .role(Role.ASSISTANT) + .content(assistantMessageContent) + .build()); + variables.put("placeholder1", placeholder1); + + // 注意:prompt 模板中未提供对应值的变量将被视为空值 + + // 4. 格式化 prompt + List messages = client.formatPrompt(prompt, variables); + + System.out.println("格式化后的消息:"); + for (Message msg : messages) { + System.out.println(" Role: " + msg.getRole() + + ", Content: " + msg.getContent()); + } + + // 5. 调用 LLM + callLLM(client, messages); + } + + System.out.println("\nJinja 模板示例执行成功!"); + } catch (Exception e) { + System.err.println("执行失败:" + e.getMessage()); + e.printStackTrace(); + } finally { + // 6. 关闭客户端 + client.close(); + } + } + + /** + * 调用 LLM + */ + private static void callLLM(CozeLoopClient client, List messages) { + try (com.coze.loop.trace.CozeLoopSpan span = + client.startSpan("llmCall", "llm")) { + // 模拟 LLM 处理 + String modelName = "gpt-4o-2024-05-13"; + + try { + Thread.sleep(1000); // 模拟网络延迟 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + // 模拟响应 + String[] respChoices = {"Hello! Can I help you?"}; + int respPromptTokens = 11; + int respCompletionTokens = 52; + + // 设置 span 属性 + span.setInput(messages); + span.setOutput(respChoices); + span.setModelProvider("openai"); + span.setModel(modelName); + span.setInputTokens(respPromptTokens); + span.setOutputTokens(respCompletionTokens); + + // 设置首次响应时间 + long firstRespTime = System.currentTimeMillis() * 1000; + span.setAttribute("start_time_first_resp", firstRespTime); + + System.out.println("\nLLM 调用完成"); + System.out.println(" 输出: " + respChoices[0]); + System.out.println(" 模型: " + modelName); + } + } +} + diff --git a/examples/src/main/java/trace/parent_child/ParentChildSpanExample.java b/examples/src/main/java/trace/parent_child/ParentChildSpanExample.java new file mode 100644 index 0000000..ac79f1a --- /dev/null +++ b/examples/src/main/java/trace/parent_child/ParentChildSpanExample.java @@ -0,0 +1,166 @@ +package trace.parent_child; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.client.CozeLoopClientBuilder; +import com.coze.loop.trace.CozeLoopSpan; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * 父子 Span 示例 + * + * 展示如何创建父子关系的 span,包括: + * - 创建父子关系的 span + * - 展示 context 传递机制 + * - 包含异步任务的追踪示例 + * + * 使用前请先设置以下环境变量: + * - COZELOOP_WORKSPACE_ID: 你的工作空间 ID + * - COZELOOP_API_TOKEN: 你的访问令牌 + */ +public class ParentChildSpanExample { + + private static final int ERROR_CODE_LLM_CALL = 600789111; + + public static void main(String[] args) { + String workspaceId = System.getenv("COZELOOP_WORKSPACE_ID"); + String apiToken = System.getenv("COZELOOP_API_TOKEN"); + + + if (workspaceId == null || apiToken == null) { + System.err.println("请设置环境变量:"); + System.err.println(" COZELOOP_WORKSPACE_ID=your_workspace_id"); + System.err.println(" COZELOOP_API_TOKEN=your_token"); + System.exit(1); + } + + CozeLoopClient client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .tokenAuth(apiToken) + .build(); + + try { + // 1. 创建根 span(因为没有父 span,所以这是新 trace 的根 span) + try (CozeLoopSpan rootSpan = client.startSpan("root_span", "main_span")) { + // 2. 设置自定义标签 + rootSpan.setAttribute("service_name", "core"); + + // 3. 设置自定义属性(这些属性会传递给子 span) + rootSpan.setAttribute("product_id", "123456654321"); + rootSpan.setAttribute("product_name", "AI bot"); + rootSpan.setAttribute("product_version", "0.0.1"); + + // 4. 设置用户 ID(会隐式设置 tag key: user_id) + rootSpan.setAttribute("user_id", "123456"); + + // 5. 调用 LLM(这会创建一个子 span) + boolean success = callLLM(client); + + if (!success) { + rootSpan.setStatusCode(ERROR_CODE_LLM_CALL); + rootSpan.setError(new RuntimeException("LLM 调用失败")); + } + + // 6. 假设需要运行一个异步任务,它的 span 是 rootSpan 的子 span + Span rootSpanContext = rootSpan.getSpan(); + CompletableFuture asyncTask = asyncRendering(client, rootSpanContext); + + // 等待异步任务完成(在实际服务中,这个延迟不是必需的) + try { + asyncTask.get(5, TimeUnit.SECONDS); + } catch (Exception e) { + System.err.println("异步任务执行异常:" + e.getMessage()); + } + } + + // 7. (可选)强制刷新 + // client.flush(); + + // 由于 asyncRending 在单独的线程中运行,它的 finish 方法可能会稍后执行。 + // 这里我们故意添加一个延迟来模拟服务的持续运行。 + // 在实际服务中,这个延迟不是必需的。 + Thread.sleep(2000); + + System.out.println("父子 Span 示例执行成功!"); + } catch (Exception e) { + System.err.println("执行失败:" + e.getMessage()); + e.printStackTrace(); + } finally { + // 8. 关闭客户端 + // 警告:一旦执行 Close,客户端将不可用,需要通过 NewClient 创建新客户端! + // 仅在需要释放资源时使用,例如关闭实例时! + client.close(); + } + } + + /** + * LLM 调用(作为 rootSpan 的子 span) + */ + private static boolean callLLM(CozeLoopClient client) { + // llmCall span 通过 context 自动成为 rootSpan 的子 span + try (CozeLoopSpan span = client.startSpan("llmCall", "llm")) { + // 模拟 LLM 处理 + String modelName = "gpt-4o-2024-05-13"; + String input = "上海天气怎么样?"; + + try { + Thread.sleep(1000); // 模拟网络延迟 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + + // 模拟响应 + String[] respChoices = {"上海天气晴朗,气温25摄氏度。"}; + int respPromptTokens = 11; + int respCompletionTokens = 52; + + // 设置 span 属性 + span.setInput(input); + span.setOutput(respChoices); + span.setModelProvider("openai"); + span.setModel(modelName); + span.setInputTokens(respPromptTokens); + span.setOutputTokens(respCompletionTokens); + + System.out.println("LLM 调用完成(子 span)"); + return true; + } + } + + /** + * 异步渲染任务(作为 rootSpan 的子 span) + * + * 注意:在实际应用中,OpenTelemetry 的 context 传播是自动的。 + * 在同一个线程中,在父 span 的 scope 内创建的子 span 会自动成为父子关系。 + * 但在异步任务中,需要手动传递 context。 + */ + private static CompletableFuture asyncRendering(CozeLoopClient client, Span parentSpan) { + // 获取当前 context(包含父 span 信息) + Context parentContext = Context.current().with(parentSpan); + + return CompletableFuture.runAsync(() -> { + // 在新的线程中设置 context + try (io.opentelemetry.context.Scope scope = parentContext.makeCurrent()) { + // 创建子 span(会自动成为 parentSpan 的子 span) + try (CozeLoopSpan asyncSpan = client.startSpan("asyncRendering", "rendering")) { + // 模拟异步处理 + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // 设置状态码 + asyncSpan.setStatusCode(0); + + System.out.println("异步渲染任务完成(子 span)"); + } + } + }); + } +} + diff --git a/examples/src/main/java/trace/prompt/TraceWithPromptExample.java b/examples/src/main/java/trace/prompt/TraceWithPromptExample.java new file mode 100644 index 0000000..12651a3 --- /dev/null +++ b/examples/src/main/java/trace/prompt/TraceWithPromptExample.java @@ -0,0 +1,165 @@ +package trace.prompt; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.client.CozeLoopClientBuilder; +import com.coze.loop.entity.Message; +import com.coze.loop.entity.Prompt; +import com.coze.loop.entity.Role; +import com.coze.loop.prompt.GetPromptParam; +import com.coze.loop.trace.CozeLoopSpan; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 追踪与提示结合示例 + * + * 展示在追踪过程中使用 prompt,包括: + * - 在追踪过程中获取 prompt + * - 格式化 prompt + * - 与 LLM 调用的完整流程 + * + * 使用前请先设置以下环境变量: + * - COZELOOP_WORKSPACE_ID: 你的工作空间 ID + * - COZELOOP_API_TOKEN: 你的访问令牌 + * + * 注意:需要在平台上创建一个 Prompt(Prompt Key 设置为 'prompt_hub_demo'), + * 并在模板中添加以下消息,然后提交版本: + * - System: You are a helpful bot, the conversation topic is {{var1}}. + * - Placeholder: placeholder1 + * - User: My question is {{var2}} + * - Placeholder: placeholder2 + */ +public class TraceWithPromptExample { + + public static void main(String[] args) { + String workspaceId = System.getenv("COZELOOP_WORKSPACE_ID"); + String apiToken = System.getenv("COZELOOP_API_TOKEN"); + + if (workspaceId == null || apiToken == null) { + System.err.println("请设置环境变量:"); + System.err.println(" COZELOOP_WORKSPACE_ID=your_workspace_id"); + System.err.println(" COZELOOP_API_TOKEN=your_token"); + System.exit(1); + } + + CozeLoopClient client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .tokenAuth(apiToken) + .build(); + + try { + // 1. 创建根 span + try (CozeLoopSpan rootSpan = client.startSpan("root_span", "main_span")) { + + // 2. 获取 prompt + Prompt prompt = client.getPrompt(GetPromptParam.builder() + .promptKey("prompt_hub_demo") + .version("0.0.1") // 如果不指定版本,将获取对应 prompt 的最新版本 + .build()); + + if (prompt == null) { + System.err.println("获取 prompt 失败:prompt 不存在"); + return; + } + + // 3. 打印 prompt 信息 + if (prompt.getPromptTemplate() != null && + prompt.getPromptTemplate().getMessages() != null) { + System.out.println("Prompt 消息数量: " + + prompt.getPromptTemplate().getMessages().size()); + } + + if (prompt.getLlmConfig() != null) { + System.out.println("Prompt LLM 配置: " + prompt.getLlmConfig()); + } + + // 4. 格式化 prompt 消息 + String userMessageContent = "Hello!"; + String assistantMessageContent = "Hello!"; + + // 准备变量 + Map variables = new HashMap<>(); + // 普通变量类型应该是 String + variables.put("var1", "artificial intelligence"); + variables.put("var2", "What is AI?"); + + // Placeholder 变量类型应该是 Message/List + List placeholder1 = new ArrayList<>(); + placeholder1.add(Message.builder() + .role(Role.USER) + .content(userMessageContent) + .build()); + placeholder1.add(Message.builder() + .role(Role.ASSISTANT) + .content(assistantMessageContent) + .build()); + variables.put("placeholder1", placeholder1); + + // 格式化 prompt + List messages = client.formatPrompt(prompt, variables); + + System.out.println("格式化后的消息数量: " + messages.size()); + for (Message msg : messages) { + System.out.println(" Role: " + msg.getRole() + + ", Content: " + msg.getContent()); + } + + // 5. 调用 LLM + callLLM(client, messages); + } + + // 6. (可选)强制刷新 + // client.flush(); + + System.out.println("追踪与提示示例执行成功!"); + } catch (Exception e) { + System.err.println("执行失败:" + e.getMessage()); + e.printStackTrace(); + } finally { + // 7. 关闭客户端 + client.close(); + } + } + + /** + * 调用 LLM + */ + private static void callLLM(CozeLoopClient client, List messages) { + try (CozeLoopSpan span = client.startSpan("llmCall", "llm")) { + // 模拟 LLM 处理 + String modelName = "gpt-4o-2024-05-13"; + + try { + Thread.sleep(1000); // 模拟网络延迟 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + + // 模拟响应 + String[] respChoices = {"Hello! Can I help you?"}; + int respPromptTokens = 11; + int respCompletionTokens = 52; + + // 设置 span 属性 + span.setInput(messages); + span.setOutput(respChoices); + span.setModelProvider("openai"); + span.setModel(modelName); + span.setInputTokens(respPromptTokens); + span.setOutputTokens(respCompletionTokens); + + // 设置首次响应时间 + long firstRespTime = System.currentTimeMillis() * 1000; + span.setAttribute("start_time_first_resp", firstRespTime); + + System.out.println("LLM 调用完成"); + System.out.println(" 输出: " + respChoices[0]); + System.out.println(" 模型: " + modelName); + } + } +} + diff --git a/examples/src/main/java/trace/simple/SimpleTraceExample.java b/examples/src/main/java/trace/simple/SimpleTraceExample.java new file mode 100644 index 0000000..f7748bf --- /dev/null +++ b/examples/src/main/java/trace/simple/SimpleTraceExample.java @@ -0,0 +1,139 @@ +package trace.simple; + +import com.coze.loop.client.CozeLoopClient; +import com.coze.loop.client.CozeLoopClientBuilder; +import com.coze.loop.trace.CozeLoopSpan; + +/** + * 简单追踪示例 + * + * 展示基本的 span 创建和使用,包括: + * - 创建 span + * - 设置 input/output + * - 设置 model 信息 + * - 设置 tokens 信息 + * - 展示 LLM 调用的完整追踪流程 + * + * 使用前请先设置以下环境变量: + * - COZELOOP_WORKSPACE_ID: 你的工作空间 ID + * - COZELOOP_API_TOKEN: 你的访问令牌 + */ +public class SimpleTraceExample { + + private static final int ERROR_CODE_LLM_CALL = 600789111; + + public static void main(String[] args) { + String workspaceId = System.getenv("COZELOOP_WORKSPACE_ID"); + String apiToken = System.getenv("COZELOOP_API_TOKEN"); + + if (workspaceId == null || apiToken == null) { + System.err.println("请设置环境变量:"); + System.err.println(" COZELOOP_WORKSPACE_ID=your_workspace_id"); + System.err.println(" COZELOOP_API_TOKEN=your_token"); + System.exit(1); + } + + CozeLoopClient client = new CozeLoopClientBuilder() + .workspaceId(workspaceId) + .tokenAuth(apiToken) + .build(); + + try { + // 1. 创建根 span + try (CozeLoopSpan rootSpan = client.startSpan("root_span", "main_span")) { + // 2. 设置自定义标签 + rootSpan.setAttribute("mode", "simple"); + rootSpan.setAttribute("node_id", 6076665L); + rootSpan.setAttribute("node_process_duration", 228.6); + rootSpan.setAttribute("is_first_node", true); + + // 3. 调用 LLM + boolean success = callLLM(client); + + if (!success) { + // 4. 如果失败,设置错误状态码和错误信息 + rootSpan.setStatusCode(ERROR_CODE_LLM_CALL); + rootSpan.setError(new RuntimeException("LLM 调用失败")); + } else { + rootSpan.setStatusCode(0); // 0 表示成功 + } + } + + // 5. (可选)强制刷新,上报所有 traces + // 警告:一般情况下不需要调用此方法,因为 spans 会自动批量上报。 + // 注意:flush 会阻塞并等待上报完成,可能导致频繁上报,影响性能。 + // client.flush(); // 注意:CozeLoopClient 可能没有 flush 方法 + + System.out.println("追踪示例执行成功!"); + } catch (Exception e) { + System.err.println("执行失败:" + e.getMessage()); + e.printStackTrace(); + } finally { + // 6. 关闭客户端,执行 flush 并关闭连接 + // 警告:一旦执行 Close,客户端将不可用,需要通过 NewClient 创建新客户端! + // 仅在需要释放资源时使用,例如关闭实例时! + client.close(); + } + } + + /** + * 模拟 LLM 调用 + */ + private static boolean callLLM(CozeLoopClient client) { + // 创建 LLM 调用的 span + try (CozeLoopSpan span = client.startSpan("llmCall", "llm")) { + // 模拟 LLM 处理 + String modelName = "gpt-4o-2024-05-13"; + String input = "上海天气怎么样?"; + + // 模拟 API 调用(实际使用时替换为真实的 LLM API 调用) + // 这里只是模拟 + try { + Thread.sleep(1000); // 模拟网络延迟 + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + + // 模拟响应 + String[] respChoices = {"上海天气晴朗,气温25摄氏度。"}; + int respPromptTokens = 11; + int respCompletionTokens = 52; + + // 设置 span 的输入 + span.setInput(input); + + // 设置 span 的输出 + span.setOutput(respChoices); + + // 设置 model provider,例如:openai, anthropic 等 + span.setModelProvider("openai"); + + // 设置 model name,例如:gpt-4-1106-preview 等 + span.setModel(modelName); + + // 设置输入 tokens 数量 + // 当设置了 input_tokens 和 output_tokens 后,会自动计算 total_tokens + span.setInputTokens(respPromptTokens); + + // 设置输出 tokens 数量 + span.setOutputTokens(respCompletionTokens); + + // 设置首次响应时间(微秒) + // 当设置了 start_time_first_resp 后,会根据 span 的 StartTime 计算 + // 一个名为 latency_first_resp 的标签,表示首次数据包的延迟 + long firstRespTime = System.currentTimeMillis() * 1000; // 转换为微秒 + span.setAttribute("start_time_first_resp", firstRespTime); + + System.out.println("LLM 调用成功"); + System.out.println(" 输入: " + input); + System.out.println(" 输出: " + respChoices[0]); + System.out.println(" 模型: " + modelName); + System.out.println(" 输入 tokens: " + respPromptTokens); + System.out.println(" 输出 tokens: " + respCompletionTokens); + + return true; + } + } +} + diff --git a/pom.xml b/pom.xml index e4c57e0..fd9ec67 100644 --- a/pom.xml +++ b/pom.xml @@ -29,7 +29,7 @@ 1.34.1 4.12.0 2.16.1 - 3.1.8 + 2.9.3 1.11.0 2.7.1 0.12.5 @@ -37,8 +37,9 @@ 2.7.18 - 5.10.1 - 5.8.0 + 5.9.3 + 1.9.3 + 4.11.0 3.25.1 @@ -68,6 +69,20 @@ ${spring-boot.version} pom import + + + org.junit.jupiter + junit-jupiter + + + org.junit.platform + junit-platform-commons + + + org.junit.platform + junit-platform-engine + + @@ -146,6 +161,36 @@ ${junit.version} test + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + org.junit.platform + junit-platform-commons + ${junit-platform.version} + test + + + org.junit.platform + junit-platform-engine + ${junit-platform.version} + test + org.mockito mockito-core