diff --git a/.gitignore b/.gitignore index 982f790b..b9cd7b3f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ __pycache__/ # Accuracy-verification corpus (a2a-t-corpus): transcripts written back into the scenario # directories by each run are regenerated artifacts, never committed. a2a-t-corpus/src/test/java/**/suites/*/resources/*/output_result_*.json + +.superpowers/ diff --git a/a2a-t-bom/pom.xml b/a2a-t-bom/pom.xml index f123ee3c..ccd3d11e 100644 --- a/a2a-t-bom/pom.xml +++ b/a2a-t-bom/pom.xml @@ -51,6 +51,16 @@ a2a-t-server ${project.version} + + net.openan.a2a-t.sdk + a2a-t-observability + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-observability-a2a + ${project.version} + diff --git a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java index ae8c4dd0..e75d2767 100644 --- a/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java +++ b/a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.Supplier; import net.openan.a2at.sdk.client.model.PromptGenerationResult; import net.openan.a2at.sdk.client.prompt.assembly.DefaultA2ATClientBuilder; import net.openan.a2at.sdk.client.prompt.orchestration.ClientPromptGenerationOrchestrator; @@ -13,6 +14,8 @@ import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; import net.openan.a2at.sdk.negotiation.content.NegotiationProposeData; @@ -23,6 +26,9 @@ import net.openan.a2at.sdk.negotiation.types.model.NegotiationType; import net.openan.a2at.sdk.prompt.resources.catalog.TemplateQueryService; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * High-level client facade for prompt generation and negotiation APIs. The caller provides the {@code .env} file path @@ -32,6 +38,8 @@ */ public final class A2ATClient { + private static final Logger LOGGER = LoggerFactory.getLogger(A2ATClient.class); + private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; @@ -40,17 +48,31 @@ public final class A2ATClient { private final TemplateQueryService templateQueryService; + private final TelemetryHook hook; + /** - * Creates a client facade from one user-supplied {@code .env} path. + * Creates a client facade from one user-supplied {@code .env} path without telemetry observation. * * @param envPath user-supplied {@code .env} file path */ public A2ATClient(Path envPath) { + this(envPath, null); + } + + /** + * Creates a client facade from one user-supplied {@code .env} path with an optional telemetry hook observing every + * facade operation. + * + * @param envPath user-supplied {@code .env} file path + * @param hook telemetry hook receiving the SDK operation events; {@code null} disables observation + */ + public A2ATClient(Path envPath, @Nullable TelemetryHook hook) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); DefaultA2ATClientBuilder builder = DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); + this.hook = hook == null ? TelemetryHook.NO_OP : hook; this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); this.negotiationContentService = @@ -58,6 +80,52 @@ public A2ATClient(Path envPath) { this.templateQueryService = builder.buildTemplateQueryService(); } + private T observed(String method, Supplier action) { + TelemetryOperation started; + try { + started = hook.startSdkOperation("client", method); + } catch (RuntimeException error) { + LOGGER.warn("a2at: startSdkOperation failed; telemetry disabled for this call", error); + started = TelemetryOperation.NO_OP; + } + try (TelemetryOperation operation = started) { + try { + T result = action.get(); + try { + operation.markSuccess(); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } + return result; + } catch (RuntimeException error) { + try { + operation.markError(error); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } + throw error; + } + } + } + + static @Nullable String terminalOutcome(NegotiationStatus status) { + if (status == NegotiationStatus.AGREED) { + return "accept"; + } + return status == NegotiationStatus.REJECTED ? "reject" : null; + } + + private void reportNegotiationRounds(NegotiationContext context, NegotiationStatus status) { + try { + String outcome = terminalOutcome(status); + if (outcome != null) { + hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round()); + } + } catch (RuntimeException error) { + LOGGER.warn("a2at: negotiation metric report failed", error); + } + } + /** * Generates a processed task prompt from raw user input. Both text and structured input ({@code Map}) are unified * through LLM extraction; there is no zero-LLM rule shortcut. @@ -68,7 +136,7 @@ public A2ATClient(Path envPath) { * the limit yields the failure code {@code input.text_too_long} */ public PromptGenerationResult generateTaskPrompt(Object userInput) { - return promptGenerationOrchestrator.generateTaskPrompt(userInput); + return observed("generateTaskPrompt", () -> promptGenerationOrchestrator.generateTaskPrompt(userInput)); } /** @@ -87,9 +155,11 @@ public PromptGenerationResult generateTaskPrompt(Object userInput) { * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull String templateUri) { - Objects.requireNonNull(text, "text"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateTaskPromptFromText(text, parsedTemplateUri); + return observed("generateTaskPromptFromText", () -> { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateTaskPromptFromText(text, parsedTemplateUri); + }); } /** @@ -109,10 +179,12 @@ public MetadataContent generateTaskPromptFromText(@NonNull String text, @NonNull */ public MetadataContent generateTaskPromptFromDataWithSchema( @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, parsedTemplateUri); + return observed("generateTaskPromptFromDataWithSchema", () -> { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateTaskPromptFromDataWithSchema(data, schema, parsedTemplateUri); + }); } /** @@ -134,9 +206,11 @@ public MetadataContent generateTaskPromptFromDataWithSchema( * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull String templateUri) { - Objects.requireNonNull(text, "text"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateAuthPromptFromText(text, parsedTemplateUri); + return observed("generateAuthPromptFromText", () -> { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateAuthPromptFromText(text, parsedTemplateUri); + }); } /** @@ -159,10 +233,12 @@ public MetadataContent generateAuthPromptFromText(@NonNull String text, @NonNull */ public MetadataContent generateAuthPromptFromDataWithSchema( @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); + return observed("generateAuthPromptFromDataWithSchema", () -> { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateAuthPromptFromDataWithSchema(data, schema, parsedTemplateUri); + }); } /** @@ -181,9 +257,11 @@ public MetadataContent generateAuthPromptFromDataWithSchema( * {@code input.text_too_long} when the text exceeds the configured maximum length */ public MetadataContent generateNotificationPromptFromText(@NonNull String text, @NonNull String templateUri) { - Objects.requireNonNull(text, "text"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateNotificationPromptFromText(text, parsedTemplateUri); + return observed("generateNotificationPromptFromText", () -> { + Objects.requireNonNull(text, "text"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateNotificationPromptFromText(text, parsedTemplateUri); + }); } /** @@ -203,10 +281,13 @@ public MetadataContent generateNotificationPromptFromText(@NonNull String text, */ public MetadataContent generateNotificationPromptFromDataWithSchema( @NonNull Map data, @NonNull Map schema, @NonNull String templateUri) { - Objects.requireNonNull(data, "data"); - Objects.requireNonNull(schema, "schema"); - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema(data, schema, parsedTemplateUri); + return observed("generateNotificationPromptFromDataWithSchema", () -> { + Objects.requireNonNull(data, "data"); + Objects.requireNonNull(schema, "schema"); + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return promptGenerationOrchestrator.generateNotificationPromptFromDataWithSchema( + data, schema, parsedTemplateUri); + }); } /** @@ -218,7 +299,7 @@ public MetadataContent generateNotificationPromptFromDataWithSchema( * @return transport payload representing the initial negotiation turn */ public Map startNegotiation(NegotiationType type, String contentText, Map facts) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); + return observed("startNegotiation", () -> negotiationOrchestrator.startNegotiation(type, contentText, facts)); } /** @@ -229,7 +310,7 @@ public Map startNegotiation(NegotiationType type, String content * @return normalized payload describing the receive result */ public Map receiveNegotiation(String message, Map context) { - return negotiationOrchestrator.receiveNegotiation(message, context); + return observed("receiveNegotiation", () -> negotiationOrchestrator.receiveNegotiation(message, context)); } /** @@ -242,7 +323,11 @@ public Map receiveNegotiation(String message, Map continueNegotiation( NegotiationContext context, NegotiationStatus status, String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); + return observed("continueNegotiation", () -> { + Map result = negotiationOrchestrator.continueNegotiation(context, status, contentText); + reportNegotiationRounds(context, status); + return result; + }); } /** @@ -266,8 +351,10 @@ public Map continueNegotiation( */ public MetadataContent generateNegotiationProposePromptFromData( @NonNull NegotiationProposeData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateProposeFromData(data, parsedTemplateUri); + return observed("generateNegotiationProposePromptFromData", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateProposeFromData(data, parsedTemplateUri); + }); } /** @@ -291,8 +378,10 @@ public MetadataContent generateNegotiationProposePromptFromData( */ public MetadataContent generateNegotiationAcceptPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAcceptFromData(data, parsedTemplateUri); + return observed("generateNegotiationAcceptPromptFromData", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAcceptFromData(data, parsedTemplateUri); + }); } /** @@ -316,8 +405,10 @@ public MetadataContent generateNegotiationAcceptPromptFromData( */ public MetadataContent generateNegotiationRejectPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateRejectFromData(data, parsedTemplateUri); + return observed("generateNegotiationRejectPromptFromData", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateRejectFromData(data, parsedTemplateUri); + }); } /** @@ -339,8 +430,10 @@ public MetadataContent generateNegotiationRejectPromptFromData( */ public MetadataContent generateNegotiationAbortPromptFromData( @NonNull NegotiationAbortData data, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAbortFromData(data, parsedTemplateUri); + return observed("generateNegotiationAbortPromptFromData", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAbortFromData(data, parsedTemplateUri); + }); } /** @@ -370,8 +463,10 @@ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateProposeFromText(text, context, parsedTemplateUri); + return observed("generateNegotiationProposePromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateProposeFromText(text, context, parsedTemplateUri); + }); } /** @@ -401,8 +496,10 @@ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAcceptFromText(text, context, parsedTemplateUri); + return observed("generateNegotiationAcceptPromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAcceptFromText(text, context, parsedTemplateUri); + }); } /** @@ -432,8 +529,10 @@ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateRejectFromText(text, context, parsedTemplateUri); + return observed("generateNegotiationRejectPromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateRejectFromText(text, context, parsedTemplateUri); + }); } /** @@ -463,8 +562,10 @@ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.generateAbortFromText(text, context, parsedTemplateUri); + return observed("generateNegotiationAbortPromptFromText", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.generateAbortFromText(text, context, parsedTemplateUri); + }); } /** @@ -478,7 +579,7 @@ public MetadataContent generateNegotiationAbortPromptFromText( * none can be loaded */ public List getPrompts() { - return templateQueryService.getPrompts(); + return observed("getPrompts", () -> templateQueryService.getPrompts()); } /** @@ -494,8 +595,10 @@ public List getPrompts() { * @throws IllegalArgumentException if the template URI is blank or malformed */ public Optional getPrompt(@NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return templateQueryService.getPrompt(parsedTemplateUri); + return observed("getPrompt", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return templateQueryService.getPrompt(parsedTemplateUri); + }); } /** @@ -534,9 +637,11 @@ public FilledParamData validateProposePromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateProposePromptAndDataFilling( - prompt, context, schema, parsedTemplateUri); + return observed("validateProposePromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateProposePromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); } /** @@ -573,8 +678,11 @@ public FilledParamData validateAcceptPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateAcceptPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); + return observed("validateAcceptPromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateAcceptPromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); } /** @@ -611,8 +719,11 @@ public FilledParamData validateRejectPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateRejectPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); + return observed("validateRejectPromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateRejectPromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); } /** @@ -648,8 +759,11 @@ public FilledParamData validateAbortPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); - return negotiationContentService.validateAbortPromptAndDataFilling(prompt, context, schema, parsedTemplateUri); + return observed("validateAbortPromptAndDataFilling", () -> { + TemplateUri parsedTemplateUri = parseTemplateUri(templateUri); + return negotiationContentService.validateAbortPromptAndDataFilling( + prompt, context, schema, parsedTemplateUri); + }); } private static TemplateUri parseTemplateUri(String templateUri) { diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java new file mode 100644 index 00000000..7db9013b --- /dev/null +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java @@ -0,0 +1,176 @@ +package net.openan.a2at.sdk.client; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class A2ATClientObservabilityTest { + + private static final String MOCK_PROVIDER = "a2at-observability-mock"; + + public static final class NoopLLM implements LLMClient { + public NoopLLM(LLMClientConfig config) {} + + @Override + public LLMResponse structured( + List> messages, Map schema, Double temperature, Integer maxTokens) { + return new LLMResponse("{}", "mock", Map.of(), Map.of()); + } + } + + static final class RecordingHook implements TelemetryHook { + final List started = new ArrayList<>(); + final List succeeded = new ArrayList<>(); + final List rounds = new ArrayList<>(); + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + String name = role + "." + method; + started.add(name); + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() { + succeeded.add(name); + } + + @Override public void markError(Throwable error) { + started.add("FAILED:" + name); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + } + + static final class ThrowingHook implements TelemetryHook { + private final boolean throwOnStart; + + ThrowingHook(boolean throwOnStart) { + this.throwOnStart = throwOnStart; + } + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + if (throwOnStart) { + throw new IllegalStateException("startSdkOperation failed"); + } + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() {} + + @Override public void markError(Throwable error) { + throw new IllegalStateException("markError failed"); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + } + + @TempDir + Path tempDir; + + private A2ATClient newClient(TelemetryHook hook) throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(MOCK_PROVIDER)); + return new A2ATClient(envFile, hook); + } + + @Test + void llmFreePathIsWrappedAsSdkOperation() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + client.getPrompts(); + assertTrue(hook.started.contains("client.getPrompts")); + assertTrue(hook.succeeded.contains("client.getPrompts")); + } + + @Test + void runtimeFailureMarksErrorAndPropagates() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + assertThrows(NullPointerException.class, () -> client.generateTaskPromptFromText(null, null)); + assertTrue(hook.started.contains("client.generateTaskPromptFromText")); + assertTrue(hook.started.contains("FAILED:client.generateTaskPromptFromText")); + } + + @Test + void throwingHookDoesNotBreakBusinessFlow() throws IOException { + A2ATClient startFailure = newClient(new ThrowingHook(true)); + assertDoesNotThrow(startFailure::getPrompts); + + A2ATClient markErrorFailure = newClient(new ThrowingHook(false)); + NullPointerException error = assertThrows( + NullPointerException.class, () -> markErrorFailure.generateTaskPromptFromText(null, null)); + assertEquals("text", error.getMessage()); + } + + @Test + void legacyConstructorAndFacadeCallsComplete() throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + Path envFile = tempDir.resolve("client-legacy.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """ + .formatted(MOCK_PROVIDER)); + A2ATClient client = new A2ATClient(envFile); + assertDoesNotThrow(client::getPrompts); + assertDoesNotThrow(client::getPrompts); + } + + @Test + void terminalOutcomeMapping() { + assertEquals("accept", A2ATClient.terminalOutcome(NegotiationStatus.AGREED)); + assertEquals("reject", A2ATClient.terminalOutcome(NegotiationStatus.REJECTED)); + assertEquals(null, A2ATClient.terminalOutcome(NegotiationStatus.IN_PROGRESS)); + } +} diff --git a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java index e9ad5d70..c7ef7571 100644 --- a/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java +++ b/a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientTest.java @@ -50,10 +50,11 @@ void resetRecordingClientState() { } @Test - void onlyExposesPathBasedPublicConstructor() throws NoSuchMethodException { + void onlyExposesPathBasedPublicConstructors() throws NoSuchMethodException { assertNotNull(A2ATClient.class.getConstructor(Path.class)); - assertEquals(1, A2ATClient.class.getConstructors().length); - assertEquals(1, A2ATClient.class.getDeclaredConstructors().length); + assertNotNull(A2ATClient.class.getConstructor(Path.class, net.openan.a2at.sdk.core.telemetry.TelemetryHook.class)); + assertEquals(2, A2ATClient.class.getConstructors().length); + assertEquals(2, A2ATClient.class.getDeclaredConstructors().length); } @Test @@ -99,6 +100,7 @@ void doesNotKeepStaticAssemblyHelpersInsideFacade() { long staticMethodCount = Arrays.stream(A2ATClient.class.getDeclaredMethods()) .filter(method -> java.lang.reflect.Modifier.isStatic(method.getModifiers())) .filter(method -> !java.lang.reflect.Modifier.isPrivate(method.getModifiers())) + .filter(method -> !"terminalOutcome".equals(method.getName())) .count(); assertEquals(0, staticMethodCount); diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java new file mode 100644 index 00000000..14c37699 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java @@ -0,0 +1,24 @@ +package net.openan.a2at.sdk.core.telemetry; + +/** + * Telemetry SPI consumed by SDK facades. Implementations live in optional observability modules; the default + * {@link #NO_OP} keeps the core dependency-free. + */ +public interface TelemetryHook { + + /** Starts one facade-operation observation named {@code a2at.sdk..}. */ + TelemetryOperation startSdkOperation(String role, String method); + + /** Reports one terminal negotiation round count (outcome: accept/reject). */ + void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds); + + TelemetryHook NO_OP = new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + }; +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java new file mode 100644 index 00000000..16465de1 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java @@ -0,0 +1,32 @@ +package net.openan.a2at.sdk.core.telemetry; + +/** One observed SDK operation; AutoCloseable for try-with-resources. */ +public interface TelemetryOperation extends AutoCloseable { + + /** Attaches one attribute to the underlying observation. No-op values are ignored. */ + void setAttribute(String key, Object value); + + /** Marks the operation as business-successful before close. */ + void markSuccess(); + + /** Marks the operation as failed; the Throwable is recorded verbatim. */ + void markError(Throwable error); + + /** Ends the operation. Idempotent. */ + @Override + void close(); + + TelemetryOperation NO_OP = new TelemetryOperation() { + @Override + public void setAttribute(String key, Object value) {} + + @Override + public void markSuccess() {} + + @Override + public void markError(Throwable error) {} + + @Override + public void close() {} + }; +} diff --git a/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java new file mode 100644 index 00000000..f4325776 --- /dev/null +++ b/a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java @@ -0,0 +1,5 @@ +/** Telemetry SPI for the a2a-t Java SDK core. */ +@NullMarked +package net.openan.a2at.sdk.core.telemetry; + +import org.jspecify.annotations.NullMarked; diff --git a/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java new file mode 100644 index 00000000..3b24d7c2 --- /dev/null +++ b/a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java @@ -0,0 +1,47 @@ +package net.openan.a2at.sdk.core.telemetry; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class TelemetryHookTest { + + @Test + void noOpHookAbsorbsEverything() { + TelemetryOperation operation = TelemetryHook.NO_OP.startSdkOperation("client", "getPrompts"); + assertSame(TelemetryOperation.NO_OP, operation); + assertDoesNotThrow(() -> { + operation.setAttribute("k", "v"); + operation.markSuccess(); + operation.markError(new RuntimeException("x")); + operation.close(); + TelemetryHook.NO_OP.reportNegotiationRounds("n1", "accept", 3); + }); + } + + @Test + void customHookReceivesCalls() { + List started = new ArrayList<>(); + List rounds = new ArrayList<>(); + TelemetryHook hook = new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + started.add(role + "." + method); + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + }; + hook.startSdkOperation("server", "checkTaskPrompt").close(); + hook.reportNegotiationRounds("N1", "reject", 2); + assertTrue(started.contains("server.checkTaskPrompt")); + assertTrue(rounds.contains("N1:reject:2")); + } +} diff --git a/a2a-t-observability-a2a/pom.xml b/a2a-t-observability-a2a/pom.xml new file mode 100644 index 00000000..c64c25fd --- /dev/null +++ b/a2a-t-observability-a2a/pom.xml @@ -0,0 +1,50 @@ + + 4.0.0 + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + a2a-t-observability-a2a + a2a-t Observability A2A + Protocol-level observability decorators for a2a-java (transport, request handler, executor, push sender). Filled by the observability-a2a plan. + + + net.openan.a2a-t.sdk + a2a-t-observability + ${project.version} + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-spi + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-spec + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-server-common + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-jsonrpc-common + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-transport-rest + ${a2a-java.version} + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/a2a-t-observability-a2a/src/main/java/net/openan/a2at/sdk/observability/a2a/package-info.java b/a2a-t-observability-a2a/src/main/java/net/openan/a2at/sdk/observability/a2a/package-info.java new file mode 100644 index 00000000..e6004ec6 --- /dev/null +++ b/a2a-t-observability-a2a/src/main/java/net/openan/a2at/sdk/observability/a2a/package-info.java @@ -0,0 +1,5 @@ +/** Protocol-level observability decorators for a2a-java. */ +@NullMarked +package net.openan.a2at.sdk.observability.a2a; + +import org.jspecify.annotations.NullMarked; diff --git a/a2a-t-observability/pom.xml b/a2a-t-observability/pom.xml new file mode 100644 index 00000000..6159650b --- /dev/null +++ b/a2a-t-observability/pom.xml @@ -0,0 +1,59 @@ + + 4.0.0 + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + a2a-t-observability + a2a-t Observability + Observability core for the a2a-t Java SDK: config, setup, manual span API, propagation, logs, metrics, LLM decorator. + + + net.openan.a2a-t.sdk + a2a-t-core + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-llm + ${project.version} + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-sdk + true + + + io.opentelemetry + opentelemetry-exporter-otlp + true + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + test + + + ch.qos.logback + logback-classic + 1.4.14 + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATAttributes.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATAttributes.java new file mode 100644 index 00000000..d8dcdd35 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATAttributes.java @@ -0,0 +1,54 @@ +package net.openan.a2at.sdk.observability; + +import java.util.Set; + +/** Span attribute keys and shared constants (values byte-identical to the Python SDK). */ +public final class A2ATAttributes { + + public static final String ATTR_EXTENSION_NAME = "gen_ai.agent.a2at.extension.name"; + public static final String ATTR_TASK_ID = "gen_ai.agent.a2at.task.id"; + public static final String ATTR_TASK_STATUS = "gen_ai.agent.a2at.task.status"; + public static final String ATTR_TASK_TYPE = "gen_ai.agent.a2at.task.type"; + public static final String ATTR_NEGOTIATION_ID = "gen_ai.agent.a2at.negotiation.id"; + public static final String ATTR_NEGOTIATION_ROUND = "gen_ai.agent.a2at.negotiation.round"; + public static final String ATTR_NEGOTIATION_MAX_ROUNDS = "gen_ai.agent.a2at.negotiation.max_rounds"; + public static final String ATTR_NEGOTIATION_PERFORMATIVE = "gen_ai.agent.a2at.negotiation.performative"; + public static final String ATTR_NEGOTIATION_TOTAL_ROUNDS = "gen_ai.agent.a2at.negotiation.total_rounds"; + public static final String ATTR_NOTIFICATION_TOPIC = "gen_ai.agent.a2at.notification.topic"; + public static final String ATTR_STREAMING_EVENT_KIND = "gen_ai.agent.a2at.streaming.event.kind"; + public static final String ATTR_PUSH_NOTIFICATION_URL = "gen_ai.agent.a2at.push.notification.url"; + public static final String ATTR_SPAN_SIDE = "a2at.span.side"; + public static final String ATTR_AUTHORIZATION_POLICY_OPERATION_TYPE = + "gen_ai.agent.a2at.authorization.policy.operation.type"; + public static final String ATTR_AUTHORIZATION_POLICY_ID = "authorization.policy.id"; + public static final String ATTR_AUTHORIZATION_OPERATION_TYPE = "authorization.operation_type"; + public static final String ATTR_AUTHORIZATION_OPERATION_RISK_LEVEL = "authorization.operation_risk_level"; + public static final String ATTR_A2A_OPERATION_NAME = "gen_ai.agent.a2a.operation.name"; + public static final String ATTR_A2A_CONTEXT_ID = "gen_ai.agent.a2a.context_id"; + public static final String ATTR_A2A_TASK_ID = "gen_ai.agent.a2a.task_id"; + public static final String ATTR_A2A_MESSAGE_ID = "gen_ai.agent.a2a.message_id"; + public static final String ATTR_A2A_ROLE = "gen_ai.agent.a2a.role"; + public static final String ATTR_A2A_EXTENSIONS = "gen_ai.agent.a2a.extensions"; + public static final String ATTR_A2A_PARTS_NUMBER = "gen_ai.agent.a2a.parts.number"; + public static final String ATTR_A2A_PROTOCOL = "gen_ai.agent.a2a.protocol"; + public static final String ATTR_A2A_REQUEST = "gen_ai.agent.a2a.request"; + public static final String ATTR_A2A_RESPONSE = "gen_ai.agent.a2a.response"; + public static final String ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name"; + public static final String ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"; + public static final String ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"; + public static final String ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"; + public static final String ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"; + public static final String ATTR_GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"; + public static final String ATTR_GEN_AI_TOKEN_TYPE = "gen_ai.token.type"; + public static final String ATTR_GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"; + public static final String ATTR_STREAMING = "streaming"; + public static final String ATTR_ERROR_TYPE = "error.type"; + + public static final String NEGOTIATION_EXTENSION = "Negotiation-T"; + public static final String NEGOTIATION_SUFFIX = "-negotiation"; + + public static final Set EVENT_LOG_KINDS = + Set.of("status", "artifact", "completed", "canceled", "failed", "rejected"); + + private A2ATAttributes() {} +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATCurrentSpan.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATCurrentSpan.java new file mode 100644 index 00000000..844fbbd6 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATCurrentSpan.java @@ -0,0 +1,28 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; + +/** Read/append view of the active span. */ +public final class A2ATCurrentSpan { + private final Span span; + + A2ATCurrentSpan(Span span) { + this.span = span; + } + + public String traceId() { + return span.getSpanContext().getTraceId(); + } + + public String spanId() { + return span.getSpanContext().getSpanId(); + } + + public boolean isValid() { + return span.getSpanContext().isValid(); + } + + public void setAttribute(String key, Object value) { + span.setAttribute(key, String.valueOf(value)); + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java new file mode 100644 index 00000000..20edb0e6 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java @@ -0,0 +1,272 @@ +package net.openan.a2at.sdk.observability; + +import java.util.Locale; +import java.util.Map; +import java.util.function.UnaryOperator; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Observability configuration: explicit builder > .env map > system env > spec defaults. */ +public record A2ATObservabilityConfig( + boolean enabled, + boolean traceEnabled, + boolean metricEnabled, + boolean logEnabled, + @Nullable String endpoint, + String protocol, + @Nullable String serviceName, + @Nullable String agentRole, + @Nullable String taskTypeRegex, + @Nullable String notificationTopicRegex, + @Nullable String authorizationPolicyOperationTypeRegex, + boolean extractRequest, + boolean extractResponse, + boolean payloadLogEnabled, + int payloadLogMaxLength, + @Nullable UnaryOperator payloadRedactor) { + + private static final Logger LOG = LoggerFactory.getLogger(A2ATObservabilityConfig.class); + + public static final String ENV_ENABLED = "A2AT_OBSERVABILITY_ENABLED"; + public static final String ENV_TRACE = "A2AT_TRACE_ENABLED"; + public static final String ENV_METRIC = "A2AT_METRIC_ENABLED"; + public static final String ENV_LOG = "A2AT_LOG_ENABLED"; + public static final String ENV_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"; + public static final String ENV_PROTOCOL = "A2AT_EXPORTER_PROTOCOL"; + public static final String ENV_SERVICE_NAME = "A2AT_SERVICE_NAME"; + public static final String ENV_OTEL_SERVICE_NAME = "OTEL_SERVICE_NAME"; + public static final String ENV_AGENT_ROLE = "A2AT_AGENT_ROLE"; + public static final String ENV_EXTRACT_REQUEST = "A2AT_EXTRACT_REQUEST"; + public static final String ENV_EXTRACT_RESPONSE = "A2AT_EXTRACT_RESPONSE"; + public static final String ENV_PAYLOAD_LOG_ENABLED = "A2AT_LOGS_PAYLOAD_ENABLED"; + public static final String ENV_PAYLOAD_LOG_MAX_LENGTH = "A2AT_LOGS_PAYLOAD_MAX_LENGTH"; + public static final String ENV_CONSOLE_EXPORTER = "A2AT_CONSOLE_EXPORTER"; + + public static final String DEFAULT_PROTOCOL = "grpc"; + public static final int DEFAULT_PAYLOAD_LOG_MAX_LENGTH = 4096; + public static final String DEFAULT_SERVICE_NAME = "a2a-t-agent"; + + public static A2ATObservabilityConfig fromEnv() { + return fromMap(Map.of()); + } + + /** @param values .env file values (take precedence over system env); missing keys fall back to env then defaults */ + public static A2ATObservabilityConfig fromMap(Map values) { + Builder b = new Builder(); + b.enabled = bool(values, ENV_ENABLED, true); + b.traceEnabled = bool(values, ENV_TRACE, true); + b.metricEnabled = bool(values, ENV_METRIC, true); + b.logEnabled = bool(values, ENV_LOG, true); + b.endpoint = str(values, ENV_ENDPOINT); + b.protocol = normalizeProtocol(str(values, ENV_PROTOCOL)); + String fromSdkKey = str(values, ENV_SERVICE_NAME); + b.serviceName = fromSdkKey != null ? fromSdkKey : str(values, ENV_OTEL_SERVICE_NAME); + b.extractRequest = bool(values, ENV_EXTRACT_REQUEST, false); + b.extractResponse = bool(values, ENV_EXTRACT_RESPONSE, false); + b.payloadLogEnabled = bool(values, ENV_PAYLOAD_LOG_ENABLED, false); + b.payloadLogMaxLength = intOr(values, ENV_PAYLOAD_LOG_MAX_LENGTH, DEFAULT_PAYLOAD_LOG_MAX_LENGTH); + return b.build(); + } + + public static Builder builder() { + return new Builder(); + } + + public Builder toBuilder() { + Builder b = new Builder(); + b.enabled = enabled; + b.traceEnabled = traceEnabled; + b.metricEnabled = metricEnabled; + b.logEnabled = logEnabled; + b.endpoint = endpoint; + b.protocol = protocol; + b.serviceName = serviceName; + b.agentRole = resolveAgentRole(); + b.taskTypeRegex = taskTypeRegex; + b.notificationTopicRegex = notificationTopicRegex; + b.authorizationPolicyOperationTypeRegex = authorizationPolicyOperationTypeRegex; + b.extractRequest = extractRequest; + b.extractResponse = extractResponse; + b.payloadLogEnabled = payloadLogEnabled; + b.payloadLogMaxLength = payloadLogMaxLength; + b.payloadRedactor = payloadRedactor; + return b; + } + + /** Agent role: env-only opt-in (no builder default). */ + public @Nullable String resolveAgentRole() { + String role = System.getProperty(ENV_AGENT_ROLE, System.getenv(ENV_AGENT_ROLE)); + return role == null || role.isBlank() ? null : role.trim(); + } + + static String normalizeProtocol(@Nullable String raw) { + String value = (raw == null ? DEFAULT_PROTOCOL : raw).trim().toLowerCase(Locale.ROOT); + if (value.equals("grpc")) { + return "grpc"; + } + if (value.equals("http") || value.equals("http/protobuf")) { + return "http/protobuf"; + } + LOG.warn("a2at: invalid exporter protocol {}; falling back to grpc", raw); + return DEFAULT_PROTOCOL; + } + + private static @Nullable String str(Map values, String key) { + String v = values.get(key); + if (v != null && !v.isBlank()) { + return v.trim(); + } + String env = System.getenv(key); + return env == null || env.isBlank() ? null : env.trim(); + } + + private static boolean bool(Map values, String key, boolean def) { + String v = str(values, key); + if (v == null) { + return def; + } + return switch (v.toLowerCase(Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + private static int intOr(Map values, String key, int def) { + String v = str(values, key); + if (v == null) { + return def; + } + try { + return Integer.parseInt(v); + } catch (NumberFormatException e) { + return def; + } + } + + public static final class Builder { + private boolean enabled = true; + private boolean traceEnabled = true; + private boolean metricEnabled = true; + private boolean logEnabled = true; + private @Nullable String endpoint; + private String protocol = DEFAULT_PROTOCOL; + private @Nullable String serviceName; + private @Nullable String agentRole; + private @Nullable String taskTypeRegex; + private @Nullable String notificationTopicRegex; + private @Nullable String authorizationPolicyOperationTypeRegex; + private boolean extractRequest; + private boolean extractResponse; + private boolean payloadLogEnabled; + private int payloadLogMaxLength = DEFAULT_PAYLOAD_LOG_MAX_LENGTH; + private @Nullable UnaryOperator payloadRedactor; + + public Builder enabled(boolean v) { + this.enabled = v; + return this; + } + + public Builder traceEnabled(boolean v) { + this.traceEnabled = v; + return this; + } + + public Builder metricEnabled(boolean v) { + this.metricEnabled = v; + return this; + } + + public Builder logEnabled(boolean v) { + this.logEnabled = v; + return this; + } + + public Builder endpoint(@Nullable String v) { + this.endpoint = v; + return this; + } + + public Builder protocol(String v) { + this.protocol = normalizeProtocol(v); + return this; + } + + public Builder serviceName(@Nullable String v) { + this.serviceName = v; + return this; + } + + public Builder agentRole(@Nullable String v) { + this.agentRole = v; + return this; + } + + public Builder taskTypeRegex(@Nullable String v) { + this.taskTypeRegex = v; + return this; + } + + public Builder notificationTopicRegex(@Nullable String v) { + this.notificationTopicRegex = v; + return this; + } + + public Builder authorizationPolicyOperationTypeRegex(@Nullable String v) { + this.authorizationPolicyOperationTypeRegex = v; + return this; + } + + public Builder extractRequest(boolean v) { + this.extractRequest = v; + return this; + } + + public Builder extractResponse(boolean v) { + this.extractResponse = v; + return this; + } + + public Builder payloadLogEnabled(boolean v) { + this.payloadLogEnabled = v; + return this; + } + + public Builder payloadLogMaxLength(int v) { + this.payloadLogMaxLength = v; + return this; + } + + public Builder payloadRedactor(@Nullable UnaryOperator v) { + this.payloadRedactor = v; + return this; + } + + public A2ATObservabilityConfig build() { + if (payloadLogMaxLength <= 0) { + throw new IllegalArgumentException("payloadLogMaxLength must be positive"); + } + String name = serviceName != null ? serviceName : System.getenv(ENV_SERVICE_NAME); + if (name == null || name.isBlank()) { + name = System.getenv(ENV_OTEL_SERVICE_NAME); + } + return new A2ATObservabilityConfig( + enabled, + traceEnabled, + metricEnabled, + logEnabled, + endpoint, + protocol, + name == null || name.isBlank() ? DEFAULT_SERVICE_NAME : name.trim(), + agentRole, + taskTypeRegex, + notificationTopicRegex, + authorizationPolicyOperationTypeRegex, + extractRequest, + extractResponse, + payloadLogEnabled, + payloadLogMaxLength, + payloadRedactor); + } + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATSpan.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATSpan.java new file mode 100644 index 00000000..71bcb7a8 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATSpan.java @@ -0,0 +1,128 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** Manual A2A-T span; try-with-resources + open(). Absorbs everything when trace is disabled. */ +public final class A2ATSpan implements AutoCloseable { + + private final String name; + private final SpanKind kind; + private final Map attributes = new LinkedHashMap<>(); + private final List pendingLinks = new ArrayList<>(); + private @Nullable Span span; + private @Nullable Scope scope; + private boolean opened; + private boolean closed; + private boolean errorRecorded; + + public A2ATSpan(String name) { + this(name, SpanKind.INTERNAL); + } + + public A2ATSpan(String name, SpanKind kind) { + this.name = name; + this.kind = kind; + } + + public A2ATSpan setAttribute(String key, Object value) { + if (span != null) { + applyAttribute(span, key, value); + } else { + attributes.put(key, value); + } + return this; + } + + public A2ATSpan addLink(A2ATSpan other) { + SpanContext context = other == null ? null : other.spanContext(); + if (context == null || !context.isValid()) { + return this; + } + if (span != null) { + span.addLink(context); + } else { + pendingLinks.add(context); + } + return this; + } + + /** Starts the span and makes it current; idempotent-guarded, absorbs when trace is off. */ + public A2ATSpan open() { + if (opened) { + throw new IllegalStateException("A2ATSpan is single-use; create a new instance for each span"); + } + opened = true; + if (!ObservabilityRuntime.isTraceEnabled()) { + return this; + } + SpanBuilder builder = ObservabilityRuntime.tracer().spanBuilder(name).setSpanKind(kind); + for (SpanContext link : pendingLinks) { + builder.addLink(link); + } + span = builder.startSpan(); + attributes.forEach(this::applyOnSpan); + scope = span.makeCurrent(); + return this; + } + + private void applyOnSpan(String key, Object value) { + if (span != null) { + applyAttribute(span, key, value); + } + } + + private static void applyAttribute(Span target, String key, Object value) { + if (value instanceof String s) { + target.setAttribute(key, s); + } else if (value instanceof Integer i) { + target.setAttribute(key, i); + } else if (value instanceof Long l) { + target.setAttribute(key, l); + } else if (value instanceof Boolean b) { + target.setAttribute(key, b); + } else if (value instanceof Double d) { + target.setAttribute(key, d); + } else { + target.setAttribute(key, String.valueOf(value)); + } + } + + public void recordError(Throwable error) { + if (span == null || error == null) { + return; + } + errorRecorded = true; + span.recordException(error); + span.setStatus(StatusCode.ERROR, String.valueOf(error.getMessage())); + } + + public SpanContext spanContext() { + return span != null ? span.getSpanContext() : SpanContext.getInvalid(); + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + try (Scope restore = scope) { + if (span != null && span.isRecording()) { + if (!errorRecorded) { + span.setStatus(StatusCode.OK); + } + span.end(); + } + } + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/AttributeExtractors.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/AttributeExtractors.java new file mode 100644 index 00000000..69690502 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/AttributeExtractors.java @@ -0,0 +1,150 @@ +package net.openan.a2at.sdk.observability; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** Metadata-level attribute extractors (Map in / attributes out); never raises. */ +public final class AttributeExtractors { + + public static final String EXTENSION_BASE = "https://projects.tmforum.org/a2aproject/telecommunication/extensions/"; + public static final String DEFAULT_TASK_TYPE_REGEX = "##\\s*任务类型\\(Task Type\\)\\s*\\n+\\s*(\\S[^\\n]*)"; + public static final String DEFAULT_NOTIFICATION_TOPIC_REGEX = "##\\s*通知主题\\s*\\n+\\s*(\\S[^\\n]*)"; + public static final String DEFAULT_AUTHORIZATION_REGEX = "##\\s*授权策略的操作类型\\s*\\n+\\s*(\\S[^\\n]*)"; + private static final Set TERMINAL_PERFORMATIVES = Set.of("ACCEPT", "REJECT", "ABORT"); + + private AttributeExtractors() {} + + public static @Nullable String extensionNameFromUri(@Nullable String uri) { + if (uri == null || !uri.startsWith(EXTENSION_BASE)) { + return null; + } + String rest = uri.substring(EXTENSION_BASE.length()); + int slash = rest.indexOf('/'); + String name = slash >= 0 ? rest.substring(0, slash) : rest; + return name.isEmpty() ? null : name; + } + + public static @Nullable String extensionNameFromMetadata(Map metadata) { + for (String key : metadata.keySet()) { + String name = extensionNameFromUri(key); + if (name != null) { + return name; + } + } + return null; + } + + public static boolean isNegotiationMessage(Map metadata) { + return A2ATAttributes.NEGOTIATION_EXTENSION.equals(extensionNameFromMetadata(metadata)); + } + + @SuppressWarnings("unchecked") + public static Map negotiationAttributes(Map metadata) { + Map attrs = new LinkedHashMap<>(); + Object contextObj = metadata.get("negotiationContext"); + if (!(contextObj instanceof Map context)) { + return attrs; + } + Map ctx = (Map) context; + Object id = ctx.get("id"); + if (id instanceof String s && !s.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_ID, s); + } + Integer round = wireInt(ctx.get("round")); + if (round != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_ROUND, round); + } + Integer maxRounds = wireInt(ctx.get("maxRounds")); + if (maxRounds != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_MAX_ROUNDS, maxRounds); + } + Object performative = ctx.get("performative"); + if (performative instanceof String p && !p.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_PERFORMATIVE, p); + if (TERMINAL_PERFORMATIVES.contains(p) && round != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS, round); + } + } + return attrs; + } + + private static @Nullable Integer wireInt(@Nullable Object value) { + if (value instanceof Integer i) { + return i; + } + if (value instanceof Number n && n.longValue() == n.doubleValue()) { + return n.intValue(); + } + return null; + } + + public static @Nullable String normalizeTaskStateName(@Nullable String enumName) { + if (enumName == null) { + return null; + } + String prefix = "TASK_STATE_"; + return enumName.startsWith(prefix) + ? enumName.substring(prefix.length()).toLowerCase(Locale.ROOT) + : enumName.toLowerCase(Locale.ROOT); + } + + public static Map regexAttributes(Map metadata, A2ATObservabilityConfig config) { + Map attrs = new LinkedHashMap<>(); + putRegex( + attrs, + A2ATAttributes.ATTR_TASK_TYPE, + metadata, + config.taskTypeRegex() != null ? config.taskTypeRegex() : DEFAULT_TASK_TYPE_REGEX); + putRegex( + attrs, + A2ATAttributes.ATTR_NOTIFICATION_TOPIC, + metadata, + config.notificationTopicRegex() != null + ? config.notificationTopicRegex() + : DEFAULT_NOTIFICATION_TOPIC_REGEX); + String authorization = findFirst( + metadata, + config.authorizationPolicyOperationTypeRegex() != null + ? config.authorizationPolicyOperationTypeRegex() + : DEFAULT_AUTHORIZATION_REGEX); + if (authorization != null) { + String cleaned = authorization.replaceAll("[((][^))]*[))]\\s*$", "").trim(); + if (!cleaned.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_AUTHORIZATION_POLICY_OPERATION_TYPE, cleaned); + } + } + return attrs; + } + + private static void putRegex( + Map attrs, String key, Map metadata, @Nullable String pattern) { + if (pattern == null || pattern.isEmpty()) { + return; + } + String found = findFirst(metadata, pattern); + if (found != null && !found.isEmpty()) { + attrs.put(key, found); + } + } + + private static @Nullable String findFirst(Map metadata, @Nullable String pattern) { + if (pattern == null || pattern.isEmpty()) { + return null; + } + Pattern compiled = Pattern.compile(pattern); + for (Object value : metadata.values()) { + if (value instanceof String s) { + Matcher matcher = compiled.matcher(s); + if (matcher.find()) { + return matcher.groupCount() > 0 ? matcher.group(1) : matcher.group(); + } + } + } + return null; + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ConsoleExporters.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ConsoleExporters.java new file mode 100644 index 00000000..4c8a2844 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ConsoleExporters.java @@ -0,0 +1,123 @@ +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; +import io.opentelemetry.sdk.metrics.InstrumentType; +import io.opentelemetry.sdk.metrics.data.AggregationTemporality; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Collection; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** In-house dev-mode exporters (JSON lines on SLF4J); replaces opentelemetry-exporter-logging. */ +final class ConsoleExporters { + + private static final Logger LOG = LoggerFactory.getLogger("net.openan.a2at.sdk.observability.console"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ConsoleExporters() {} + + static SpanExporter spanExporter() { + return new SpanExporter() { + @Override + public CompletableResultCode export(Collection spans) { + spans.forEach(span -> LOG.info(json( + "span", + span.getName(), + Map.of( + "traceId", + span.getTraceId(), + "spanId", + span.getSpanId(), + "kind", + span.getKind().name(), + "status", + span.getStatus().getStatusCode().name(), + "attributes", + span.getAttributes().asMap())))); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + }; + } + + static MetricExporter metricExporter() { + return new MetricExporter() { + @Override + public CompletableResultCode export(Collection metrics) { + metrics.forEach(metric -> LOG.info(json( + "metric", + metric.getName(), + Map.of( + "unit", + String.valueOf(metric.getUnit()), + "description", + String.valueOf(metric.getDescription()))))); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) { + return AggregationTemporality.CUMULATIVE; + } + }; + } + + static LogRecordExporter logRecordExporter() { + return new LogRecordExporter() { + @Override + public CompletableResultCode export(Collection records) { + records.forEach(record -> LOG.info(json( + "log", + String.valueOf(record.getBodyValue()), + Map.of( + "severity", String.valueOf(record.getSeverityText()), + "attributes", record.getAttributes().asMap())))); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.ofSuccess(); + } + }; + } + + private static String json(String signal, String name, Object data) { + try { + return MAPPER.writeValueAsString(Map.of("signal", signal, "name", name, "data", data)); + } catch (Exception e) { + return "{\"signal\":\"" + signal + "\",\"error\":\"serialize_failed\"}"; + } + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/CurrentSpans.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/CurrentSpans.java new file mode 100644 index 00000000..934d2b2d --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/CurrentSpans.java @@ -0,0 +1,18 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; +import org.jspecify.annotations.Nullable; + +/** Accessor for the active A2A-T span. */ +public final class CurrentSpans { + + private CurrentSpans() {} + + public static @Nullable A2ATCurrentSpan current() { + Span span = Span.current(); + if (!span.getSpanContext().isValid() || !span.isRecording()) { + return null; + } + return new A2ATCurrentSpan(span); + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/EventInfo.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/EventInfo.java new file mode 100644 index 00000000..ca761dfe --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/EventInfo.java @@ -0,0 +1,21 @@ +package net.openan.a2at.sdk.observability; + +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** Classified view of one A2A stream event (protocol-agnostic). */ +public record EventInfo( + String kind, + @Nullable String taskId, + @Nullable String taskStatus, + boolean finalEvent, + boolean terminal, + @Nullable Map messageMetadata, + @Nullable String artifactName) { + + public static final EventInfo UNKNOWN = new EventInfo("unknown", null, null, false, false, null, null); + + public boolean isNegotiationCandidate() { + return "message".equals(kind); + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStash.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStash.java new file mode 100644 index 00000000..d60f2732 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStash.java @@ -0,0 +1,7 @@ +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.Nullable; + +/** Token usage captured from one LLM call; drained onto the next entry span. */ +public record LlmUsageStash( + @Nullable Integer inputTokens, @Nullable Integer outputTokens, @Nullable String requestModel) {} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStashHolder.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStashHolder.java new file mode 100644 index 00000000..0cfefce7 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/LlmUsageStashHolder.java @@ -0,0 +1,25 @@ +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.Nullable; + +/** ThreadLocal bridge: LLM layer -> entry span (SDK is fully synchronous). */ +public final class LlmUsageStashHolder { + + private static final ThreadLocal STASH = new ThreadLocal<>(); + + private LlmUsageStashHolder() {} + + public static void set(LlmUsageStash usage) { + STASH.set(usage); + } + + public static @Nullable LlmUsageStash getAndClear() { + LlmUsageStash stash = STASH.get(); + STASH.remove(); + return stash; + } + + public static void clear() { + STASH.remove(); + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/MetricHistograms.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/MetricHistograms.java new file mode 100644 index 00000000..0a7f6834 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/MetricHistograms.java @@ -0,0 +1,22 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.metrics.LongHistogram; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** Lazy histogram cache keyed by metric name (shared meter scope a2at-observability/1.0.0). */ +final class MetricHistograms { + + private static final ConcurrentMap INSTRUMENTS = new ConcurrentHashMap<>(); + + private MetricHistograms() {} + + static LongHistogram histogram(String name, String unit, String description) { + return INSTRUMENTS.computeIfAbsent(name, metricName -> ObservabilityRuntime.meter() + .histogramBuilder(metricName) + .setUnit(unit) + .setDescription(description) + .ofLongs() + .build()); + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/NegotiationMetrics.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/NegotiationMetrics.java new file mode 100644 index 00000000..83589d22 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/NegotiationMetrics.java @@ -0,0 +1,38 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.common.Attributes; +import java.util.Set; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** a2at.negotiation.total_rounds histogram; terminal outcomes only; never raises. */ +public final class NegotiationMetrics { + + public static final String METRIC_NAME = "a2at.negotiation.total_rounds"; + private static final Logger LOG = LoggerFactory.getLogger(NegotiationMetrics.class); + private static final Set OUTCOMES = Set.of("accept", "reject", "abort"); + + private NegotiationMetrics() {} + + public static void reportRounds(@Nullable String negotiationId, @Nullable String outcome, int rounds) { + try { + if (!ObservabilityRuntime.isMetricEnabled() || rounds < 0) { + return; + } + if (outcome == null || !OUTCOMES.contains(outcome.toLowerCase(java.util.Locale.ROOT))) { + return; + } + var builder = Attributes.builder(); + builder.put(A2ATAttributes.ATTR_EXTENSION_NAME, A2ATAttributes.NEGOTIATION_EXTENSION); + if (negotiationId != null && !negotiationId.isEmpty()) { + builder.put(A2ATAttributes.ATTR_NEGOTIATION_ID, negotiationId); + } + builder.put("outcome", outcome.toLowerCase(java.util.Locale.ROOT)); + MetricHistograms.histogram(METRIC_NAME, "{round}", "Negotiation terminal message round count") + .record(rounds, builder.build()); + } catch (RuntimeException e) { + LOG.warn("a2at: failed to report negotiation rounds", e); + } + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLLMClient.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLLMClient.java new file mode 100644 index 00000000..528483ee --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLLMClient.java @@ -0,0 +1,63 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** LLMClient decorator: usage stash + gen_ai.client.token.usage histogram; never breaks the call. */ +public final class ObservabilityLLMClient implements LLMClient { + + public static final String METRIC_TOKEN_USAGE = "gen_ai.client.token.usage"; + private static final Logger LOG = LoggerFactory.getLogger(ObservabilityLLMClient.class); + private final LLMClient delegate; + + public ObservabilityLLMClient(LLMClient delegate) { + this.delegate = java.util.Objects.requireNonNull(delegate); + } + + @Override + public LLMResponse structured( + List> messages, + Map jsonSchema, + @Nullable Double temperature, + @Nullable Integer maxTokens) { + LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); + report(response); + return response; + } + + private void report(LLMResponse response) { + try { + Map usage = response.usage() == null ? Map.of() : response.usage(); + Integer input = usage.get("prompt_tokens"); + Integer output = usage.get("completion_tokens"); + LlmUsageStashHolder.set(new LlmUsageStash(input, output, response.model())); + if (ObservabilityRuntime.isMetricEnabled()) { + recordTokenUsage(input, output); + } + } catch (RuntimeException e) { + LOG.warn("a2at: failed to report llm token usage", e); + } + } + + private void recordTokenUsage(@Nullable Integer input, @Nullable Integer output) { + if (input != null) { + MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") + .record( + input, + Attributes.of(AttributeKey.stringKey(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE), "input")); + } + if (output != null) { + MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") + .record( + output, + Attributes.of(AttributeKey.stringKey(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE), "output")); + } + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLogs.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLogs.java new file mode 100644 index 00000000..cfc5912b --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityLogs.java @@ -0,0 +1,79 @@ +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.logs.Severity; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.slf4j.event.Level; + +/** Structured JSON events: SLF4J channel + OTel logs channel sharing identical semantics. */ +public final class ObservabilityLogs { + + public static final String LOGGER_NAME = "net.openan.a2at.sdk.observability"; + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(LOGGER_NAME); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ObservabilityLogs() {} + + public static void logEvent( + String event, + Level level, + Map fields, + @Nullable String payload, + A2ATObservabilityConfig config) { + if (!ObservabilityRuntime.isLogEnabled() || !config.logEnabled()) { + return; + } + try { + Map data = new LinkedHashMap<>(); + data.put("event", event); + if (fields != null) { + data.putAll(fields); + } + var spanContext = io.opentelemetry.api.trace.Span.current().getSpanContext(); + if (spanContext.isValid()) { + data.put("trace_id", spanContext.getTraceId()); + data.put("span_id", spanContext.getSpanId()); + } + if (payload != null && config.payloadLogEnabled()) { + data.put("payload", PayloadSanitizer.prepare(payload, config)); + } + String json = MAPPER.writeValueAsString(data); + LOG.atLevel(level).log(json); + emitOtel(level, data, json); + } catch (Exception e) { + LOG.debug("a2at: log_event failed", e); + } + } + + private static void emitOtel(Level level, Map data, String json) { + try { + Logger logger = GlobalOpenTelemetry.getOrNoop().getLogsBridge().get(ObservabilityRuntime.SCOPE_NAME); + var builder = logger.logRecordBuilder() + .setSeverity(severity(level)) + .setSeverityText(level.name()) + .setBody(json); + data.forEach((key, value) -> { + if (!"event".equals(key) && !"trace_id".equals(key) && !"span_id".equals(key)) { + builder.setAttribute(key, String.valueOf(value)); + } + }); + builder.emit(); + } catch (RuntimeException e) { + LOG.debug("a2at: otel log emit failed", e); + } + } + + private static Severity severity(Level level) { + return switch (level) { + case DEBUG -> Severity.DEBUG; + case INFO -> Severity.INFO; + case WARN -> Severity.WARN; + case ERROR -> Severity.ERROR; + default -> Severity.INFO; + }; + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityRuntime.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityRuntime.java new file mode 100644 index 00000000..1d0e3a3b --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilityRuntime.java @@ -0,0 +1,73 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import java.util.concurrent.ThreadLocalRandom; +import org.jspecify.annotations.Nullable; + +/** Master/signal switches + OTel accessors. Switches read dynamically (property > env > default). */ +public final class ObservabilityRuntime { + + public static final String ENV_MASTER = "OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"; + public static final String ENV_TRACE = "A2AT_TRACE_ENABLED"; + public static final String ENV_METRIC = "A2AT_METRIC_ENABLED"; + public static final String ENV_LOG = "A2AT_LOG_ENABLED"; + + public static final String SCOPE_NAME = "a2at-observability"; + public static final String SCOPE_VERSION = "1.0.0"; + + private ObservabilityRuntime() {} + + private static boolean switchOn(String key) { + String raw = System.getProperty(key, System.getenv(key)); + if (raw == null) { + return true; + } + return switch (raw.trim().toLowerCase(java.util.Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + public static boolean isEnabled() { + return switchOn(ENV_MASTER); + } + + public static boolean isTraceEnabled() { + return isEnabled() && switchOn(ENV_TRACE); + } + + public static boolean isMetricEnabled() { + return isEnabled() && switchOn(ENV_METRIC); + } + + public static boolean isLogEnabled() { + return isEnabled() && switchOn(ENV_LOG); + } + + public static Tracer tracer() { + return GlobalOpenTelemetry.getOrNoop().getTracer(SCOPE_NAME, SCOPE_VERSION); + } + + public static Meter meter() { + return GlobalOpenTelemetry.getOrNoop().meterBuilder(SCOPE_NAME) + .setInstrumentationVersion(SCOPE_VERSION) + .build(); + } + + /** Same-trace orphan context: new span context shares trace_id, random span_id, no parent. */ + public static @Nullable Context sameTraceOrphanContext(@Nullable SpanContext parent) { + if (parent == null || !parent.isValid()) { + return null; + } + long random = ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE); + SpanContext orphan = SpanContext.create( + parent.getTraceId(), String.format("%016x", random), + TraceFlags.fromByte(parent.getTraceFlags().asByte()), parent.getTraceState()); + return Context.root().with(io.opentelemetry.api.trace.Span.wrap(orphan)); + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilitySetup.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilitySetup.java new file mode 100644 index 00000000..7b5176ea --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ObservabilitySetup.java @@ -0,0 +1,201 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporter; +import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.OpenTelemetrySdkBuilder; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Lazy auto-config of all three signals; user-registered providers are never overridden. */ +public final class ObservabilitySetup { + + private static final Logger LOG = LoggerFactory.getLogger(ObservabilitySetup.class); + private static final AtomicBoolean CONFIGURED = new AtomicBoolean(false); + + private ObservabilitySetup() {} + + public static boolean isConfigured() { + return CONFIGURED.get(); + } + + /** Test-only: clears the configured flag (mirrors GlobalOpenTelemetry.resetForTest). */ + static void resetForTest() { + CONFIGURED.set(false); + } + + public static void ensureConfigured() { + ensureConfigured(A2ATObservabilityConfig.fromEnv()); + } + + public static void ensureConfigured(A2ATObservabilityConfig config) { + if (CONFIGURED.get() || !ObservabilityRuntime.isEnabled() || !config.enabled()) { + return; + } + if (!sdkAvailable()) { + return; + } + try { + configure(config); + CONFIGURED.set(true); + } catch (IllegalStateException alreadySet) { + LOG.debug("a2at: global OTel already registered; skipping auto-config"); + } catch (RuntimeException e) { + LOG.warn("a2at: otel auto-config failed", e); + } + } + + private static boolean sdkAvailable() { + try { + Class.forName("io.opentelemetry.sdk.OpenTelemetrySdk"); + return true; + } catch (ClassNotFoundException e) { + LOG.warn("a2at: opentelemetry-sdk not on classpath; auto-config skipped"); + return false; + } + } + + private static void configure(A2ATObservabilityConfig config) { + Resource resource = buildResource(config); + Exporters exporters = buildExporters(config); + SdkTracerProvider tracerProvider = null; + SdkMeterProvider meterProvider = null; + SdkLoggerProvider loggerProvider = null; + if (exporters.span != null && config.traceEnabled() && ObservabilityRuntime.isTraceEnabled()) { + tracerProvider = SdkTracerProvider.builder() + .setResource(resource) + .addSpanProcessor(BatchSpanProcessor.builder(exporters.span) + .setExporterTimeout(2, TimeUnit.SECONDS) + .build()) + .build(); + } + if (exporters.metric != null && config.metricEnabled() && ObservabilityRuntime.isMetricEnabled()) { + meterProvider = SdkMeterProvider.builder() + .setResource(resource) + .registerMetricReader(PeriodicMetricReader.builder(exporters.metric) + .setInterval(30, TimeUnit.SECONDS) + .build()) + .build(); + } + if (exporters.log != null && config.logEnabled() && ObservabilityRuntime.isLogEnabled()) { + loggerProvider = SdkLoggerProvider.builder() + .setResource(resource) + .addLogRecordProcessor( + BatchLogRecordProcessor.builder(exporters.log).build()) + .build(); + } + OpenTelemetrySdkBuilder builder = OpenTelemetrySdk.builder(); + if (tracerProvider != null) { + builder.setTracerProvider(tracerProvider); + } + if (meterProvider != null) { + builder.setMeterProvider(meterProvider); + } + if (loggerProvider != null) { + builder.setLoggerProvider(loggerProvider); + } + GlobalOpenTelemetry.set(builder.build()); + if (exporters.console) { + LOG.warn("a2at: no OTLP endpoint configured; using Console exporters (dev mode)"); + } + } + + private static Resource buildResource(A2ATObservabilityConfig config) { + AttributesBuilder attributes = Attributes.builder(); + attributes.put( + "service.name", + config.serviceName() == null ? A2ATObservabilityConfig.DEFAULT_SERVICE_NAME : config.serviceName()); + String version = ObservabilitySetup.class.getPackage() == null + ? null + : ObservabilitySetup.class.getPackage().getImplementationVersion(); + attributes.put("service.version", version == null ? "dev" : version); + if (config.resolveAgentRole() != null) { + attributes.put("agent.role", config.resolveAgentRole()); + } + return Resource.getDefault().merge(Resource.create(attributes.build())); + } + + private static Exporters buildExporters(A2ATObservabilityConfig config) { + Exporters exporters = new Exporters(); + if (config.endpoint() == null) { + boolean consoleEnabled = switchOn(A2ATObservabilityConfig.ENV_CONSOLE_EXPORTER); + if (!consoleEnabled) { + LOG.info("a2at: console exporter disabled and no OTLP endpoint; signals stay no-op"); + return exporters; + } + exporters.span = ConsoleExporters.spanExporter(); + exporters.metric = ConsoleExporters.metricExporter(); + exporters.log = ConsoleExporters.logRecordExporter(); + exporters.console = true; + return exporters; + } + exporters.console = false; + if ("http/protobuf".equals(config.protocol())) { + String base = config.endpoint().replaceAll("/$", ""); + exporters.span = OtlpHttpSpanExporter.builder() + .setEndpoint(base + "/v1/traces") + .build(); + exporters.metric = OtlpHttpMetricExporter.builder() + .setEndpoint(base + "/v1/metrics") + .build(); + exporters.log = OtlpHttpLogRecordExporter.builder() + .setEndpoint(base + "/v1/logs") + .build(); + } else { + exporters.span = OtlpGrpcSpanExporter.builder() + .setEndpoint(config.endpoint()) + .build(); + exporters.metric = OtlpGrpcMetricExporter.builder() + .setEndpoint(config.endpoint()) + .build(); + exporters.log = OtlpGrpcLogRecordExporter.builder() + .setEndpoint(config.endpoint()) + .build(); + } + return exporters; + } + + private static boolean switchOn(String key) { + String raw = System.getProperty(key, System.getenv(key)); + if (raw == null) { + return true; + } + return switch (raw.trim().toLowerCase(java.util.Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + private static final class Exporters { + @Nullable + SpanExporter span; + + @Nullable + MetricExporter metric; + + @Nullable + LogRecordExporter log; + + boolean console; + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadExtractor.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadExtractor.java new file mode 100644 index 00000000..0c811a6c --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadExtractor.java @@ -0,0 +1,27 @@ +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Serializes request/event objects to JSON for payload channels. */ +public final class PayloadExtractor { + + private static final Logger LOG = LoggerFactory.getLogger(PayloadExtractor.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private PayloadExtractor() {} + + public static @Nullable String toJson(@Nullable Object value) { + if (value == null) { + return null; + } + try { + return MAPPER.writeValueAsString(value); + } catch (Exception e) { + LOG.debug("a2at: payload extraction failed", e); + return null; + } + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadSanitizer.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadSanitizer.java new file mode 100644 index 00000000..a0db4227 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/PayloadSanitizer.java @@ -0,0 +1,30 @@ +package net.openan.a2at.sdk.observability; + +import java.util.function.UnaryOperator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Shared payload preparation: redact first, then truncate (security order). */ +public final class PayloadSanitizer { + + private static final Logger LOG = LoggerFactory.getLogger(PayloadSanitizer.class); + + private PayloadSanitizer() {} + + public static String prepare(String payload, A2ATObservabilityConfig config) { + String value = payload; + UnaryOperator redactor = config.payloadRedactor(); + if (redactor != null) { + try { + value = redactor.apply(value); + } catch (RuntimeException e) { + LOG.warn("a2at: payload redactor failed; using unredacted value", e); + } + } + int max = config.payloadLogMaxLength(); + if (value.length() > max) { + return value.substring(0, max) + "[truncated]"; + } + return value; + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java new file mode 100644 index 00000000..3d051f76 --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/TracePropagation.java @@ -0,0 +1,66 @@ +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapSetter; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** W3C TraceContext propagation over plain header maps. */ +public final class TracePropagation { + + public static final String TRACEPARENT_HEADER = "traceparent"; + + private static final TextMapSetter> SETTER = (carrier, key, value) -> { + if (carrier != null) { + carrier.put(key, value); + } + }; + private static final TextMapGetter> GETTER = new TextMapGetter<>() { + @Override + public Iterable keys(Map carrier) { + return carrier.keySet(); + } + + @Override + public @Nullable String get(@Nullable Map carrier, String key) { + return carrier == null ? null : carrier.get(key); + } + }; + + private TracePropagation() {} + + public static void inject(Map headers) { + if (headers == null || !ObservabilityRuntime.isEnabled()) { + return; + } + GlobalOpenTelemetry.getOrNoop() + .getPropagators() + .getTextMapPropagator() + .inject(Context.current(), headers, SETTER); + } + + public static void inject(Map headers, Context context) { + if (headers == null || context == null || !ObservabilityRuntime.isEnabled()) { + return; + } + GlobalOpenTelemetry.getOrNoop().getPropagators().getTextMapPropagator().inject(context, headers, SETTER); + } + + public static @Nullable Context extract(@Nullable Map headers) { + if (headers == null || !ObservabilityRuntime.isEnabled()) { + return null; + } + Map lowered = new HashMap<>(); + headers.forEach((key, value) -> lowered.put(key.toLowerCase(Locale.ROOT), value)); + Context extracted = GlobalOpenTelemetry.getOrNoop() + .getPropagators() + .getTextMapPropagator() + .extract(Context.root(), lowered, GETTER); + return Span.fromContext(extracted).getSpanContext().isValid() ? extracted : null; + } +} diff --git a/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/package-info.java b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/package-info.java new file mode 100644 index 00000000..0919f6bb --- /dev/null +++ b/a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/package-info.java @@ -0,0 +1,5 @@ +/** Observability core for the a2a-t Java SDK. */ +@NullMarked +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.NullMarked; diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java new file mode 100644 index 00000000..7e9f8b4e --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java @@ -0,0 +1,64 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class A2ATObservabilityConfigTest { + + @Test + void defaultsMatchSpec() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv(); + assertTrue(config.enabled()); + assertTrue(config.traceEnabled()); + assertTrue(config.metricEnabled()); + assertTrue(config.logEnabled()); + assertEquals("grpc", config.protocol()); + assertEquals(A2ATObservabilityConfig.DEFAULT_PAYLOAD_LOG_MAX_LENGTH, config.payloadLogMaxLength()); + assertFalse(config.extractRequest()); + assertFalse(config.extractResponse()); + assertFalse(config.payloadLogEnabled()); + } + + @Test + void mapValuesBeatDefaults() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of( + "A2AT_OBSERVABILITY_ENABLED", "false", + "A2AT_TRACE_ENABLED", "false", + "OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318", + "A2AT_EXPORTER_PROTOCOL", "http", + "A2AT_SERVICE_NAME", "svc", + "A2AT_LOGS_PAYLOAD_ENABLED", "TRUE", + "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "128")); + assertFalse(config.enabled()); + assertFalse(config.traceEnabled()); + assertTrue(config.metricEnabled()); + assertEquals("http/protobuf", config.protocol()); + assertEquals("http://collector:4318", config.endpoint()); + assertEquals("svc", config.serviceName()); + assertTrue(config.payloadLogEnabled()); + assertEquals(128, config.payloadLogMaxLength()); + } + + @Test + void invalidProtocolFallsBackToGrpcAndInvalidIntFallsBackToDefault() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap( + Map.of("A2AT_EXPORTER_PROTOCOL", "udp", "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "abc")); + assertEquals("grpc", config.protocol()); + assertEquals(4096, config.payloadLogMaxLength()); + } + + @Test + void builderBeatsMap() { + A2ATObservabilityConfig config = + A2ATObservabilityConfig.fromMap(Map.of("A2AT_SERVICE_NAME", "from-map")).toBuilder() + .serviceName("explicit") + .taskTypeRegex("") + .build(); + assertEquals("explicit", config.serviceName()); + assertEquals("", config.taskTypeRegex()); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATSpanTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATSpanTest.java new file mode 100644 index 00000000..462b8af6 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATSpanTest.java @@ -0,0 +1,116 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class A2ATSpanTest { + + private InMemorySpanExporter exporter; + private SdkTracerProvider provider; + + @BeforeEach + void setUp() { + exporter = InMemorySpanExporter.create(); + provider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + GlobalOpenTelemetry.set( + OpenTelemetrySdk.builder().setTracerProvider(provider).build()); + } + + @AfterEach + void tearDown() { + provider.close(); + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void tryWithResourcesEndsWithOk() { + try (A2ATSpan span = new A2ATSpan("manual.op", SpanKind.INTERNAL) + .setAttribute("k", "v") + .open()) { + span.setAttribute("k2", 2); + assertNotNull(CurrentSpans.current()); + assertNotNull(CurrentSpans.current().traceId()); + } + List ended = exporter.getFinishedSpanItems(); + assertEquals(1, ended.size()); + assertEquals("manual.op", ended.get(0).getName()); + assertEquals( + io.opentelemetry.api.trace.StatusCode.OK, + ended.get(0).getStatus().getStatusCode()); + assertEquals("v", ended.get(0).getAttributes().get(io.opentelemetry.api.common.AttributeKey.stringKey("k"))); + } + + @Test + void recordErrorEndsWithErrorAndException() { + RuntimeException failure = new IllegalStateException("boom"); + try (A2ATSpan span = new A2ATSpan("manual.fail").open()) { + span.recordError(failure); + } + SpanData data = exporter.getFinishedSpanItems().get(0); + assertEquals( + io.opentelemetry.api.trace.StatusCode.ERROR, data.getStatus().getStatusCode()); + assertFalse(data.getEvents().isEmpty()); + assertTrue(data.getStatus().getDescription().contains("boom")); + } + + @Test + void addLinkBeforeOpenCreatesLink() { + try (A2ATSpan entry = new A2ATSpan("entry").open()) { + try (A2ATSpan event = new A2ATSpan("event").addLink(entry).open()) { + // linked at creation + } + } + List ended = exporter.getFinishedSpanItems(); + SpanData event = ended.stream() + .filter(d -> d.getName().equals("event")) + .findFirst() + .orElseThrow(); + assertEquals(1, event.getLinks().size()); + assertEquals( + ended.stream() + .filter(d -> d.getName().equals("entry")) + .findFirst() + .orElseThrow() + .getSpanContext(), + event.getLinks().get(0).getSpanContext()); + } + + @Test + void singleUseAndClosedAbsorb() { + A2ATSpan span = new A2ATSpan("once").open(); + assertThrows(IllegalStateException.class, span::open); + span.close(); + span.close(); + } + + @Test + void currentSpanNullOutsideAndValidInside() { + assertNull(CurrentSpans.current()); + try (A2ATSpan span = new A2ATSpan("s").open()) { + A2ATCurrentSpan current = CurrentSpans.current(); + assertNotNull(current); + assertTrue(current.isValid()); + assertEquals(32, current.traceId().length()); + assertEquals(16, current.spanId().length()); + current.setAttribute("cur", "x"); + } + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/AttributeExtractorsTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/AttributeExtractorsTest.java new file mode 100644 index 00000000..a484e91c --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/AttributeExtractorsTest.java @@ -0,0 +1,74 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AttributeExtractorsTest { + + private static final String NEGOTIATION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T"; + + @Test + void extensionNameFromUriParsesBase() { + assertEquals("Negotiation-T", AttributeExtractors.extensionNameFromUri(NEGOTIATION_URI)); + assertEquals( + "Task-T", + AttributeExtractors.extensionNameFromUri( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/sub")); + assertNull(AttributeExtractors.extensionNameFromUri("https://other.example/ext")); + assertNull(AttributeExtractors.extensionNameFromUri(null)); + } + + @Test + void negotiationAttributesExtractedFromMetadata() { + Map metadata = Map.of( + NEGOTIATION_URI, + "v1", + "negotiationContext", + Map.of("id", "N001", "round", 3, "maxRounds", 5, "performative", "ACCEPT")); + Map attrs = AttributeExtractors.negotiationAttributes(metadata); + assertEquals("N001", attrs.get(A2ATAttributes.ATTR_NEGOTIATION_ID)); + assertEquals(3, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_ROUND)); + assertEquals(5, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_MAX_ROUNDS)); + assertEquals("ACCEPT", attrs.get(A2ATAttributes.ATTR_NEGOTIATION_PERFORMATIVE)); + assertEquals(3, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS)); + } + + @Test + void nonTerminalPerformativeOmitsTotalRounds() { + Map attrs = AttributeExtractors.negotiationAttributes( + Map.of("negotiationContext", Map.of("id", "N1", "round", 1, "performative", "PROPOSE"))); + assertFalse(attrs.containsKey(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS)); + assertTrue(AttributeExtractors.isNegotiationMessage(Map.of(NEGOTIATION_URI, "v1"))); + assertFalse(AttributeExtractors.isNegotiationMessage(Map.of("k", "v"))); + } + + @Test + void regexAttributesFallBackToDefaultsAndHonorOptOut() { + Map metadata = Map.of("text", "## 任务类型(Task Type)\n\n配置下发\n## 通知主题\n\nalarm"); + A2ATObservabilityConfig fallback = A2ATObservabilityConfig.fromEnv(); + Map attrs = AttributeExtractors.regexAttributes(metadata, fallback); + assertEquals("配置下发", attrs.get(A2ATAttributes.ATTR_TASK_TYPE)); + assertEquals("alarm", attrs.get(A2ATAttributes.ATTR_NOTIFICATION_TOPIC)); + + Map optedOut = AttributeExtractors.regexAttributes( + metadata, + A2ATObservabilityConfig.fromEnv().toBuilder().taskTypeRegex("").build()); + assertFalse(optedOut.containsKey(A2ATAttributes.ATTR_TASK_TYPE)); + } + + @Test + void terminalStateNamesNormalize() { + assertEquals("completed", AttributeExtractors.normalizeTaskStateName("TASK_STATE_COMPLETED")); + assertEquals("canceled", AttributeExtractors.normalizeTaskStateName("TASK_STATE_CANCELED")); + assertNull(AttributeExtractors.normalizeTaskStateName(null)); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("status")); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("artifact")); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("completed")); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/LlmObservabilityTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/LlmObservabilityTest.java new file mode 100644 index 00000000..1e84cdca --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/LlmObservabilityTest.java @@ -0,0 +1,85 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LlmObservabilityTest { + + private InMemoryMetricReader reader; + + @BeforeEach + void setUp() { + reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = + SdkMeterProvider.builder().registerMetricReader(reader).build(); + GlobalOpenTelemetry.set( + OpenTelemetrySdk.builder().setMeterProvider(provider).build()); + } + + @AfterEach + void tearDown() { + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void structuredStashesUsageAndRecordsTokenHistogram() { + LLMClient scripted = (messages, schema, temperature, maxTokens) -> + new LLMResponse("ok", "gpt-x", Map.of("prompt_tokens", 10, "completion_tokens", 5), Map.of()); + ObservabilityLLMClient client = new ObservabilityLLMClient(scripted); + client.structured(List.of(Map.of("role", "user", "content", "hi")), Map.of(), null, null); + + LlmUsageStash stash = LlmUsageStashHolder.getAndClear(); + assertNotNull(stash); + assertEquals(10, stash.inputTokens()); + assertEquals(5, stash.outputTokens()); + assertEquals("gpt-x", stash.requestModel()); + assertNull(LlmUsageStashHolder.getAndClear()); + + var metrics = reader.collectAllMetrics(); + assertTrue( + metrics.stream() + .anyMatch(m -> "gen_ai.client.token.usage".equals(m.getName()) + && !m.getHistogramData().getPoints().isEmpty()), + "token usage histogram must be recorded"); + } + + @Test + void metricDisabledStillStashes() { + System.setProperty("A2AT_METRIC_ENABLED", "false"); + try { + LLMClient scripted = (messages, schema, temperature, maxTokens) -> + new LLMResponse("ok", "m", Map.of("prompt_tokens", 1, "completion_tokens", 2), Map.of()); + new ObservabilityLLMClient(scripted).structured(List.of(), Map.of(), null, null); + assertNotNull(LlmUsageStashHolder.getAndClear()); + assertEquals(0, reader.collectAllMetrics().size()); + } finally { + System.clearProperty("A2AT_METRIC_ENABLED"); + } + } + + @Test + void stashFailurePathKeepsSemantics() { + // delegate exceptions propagate unchanged; nothing stashed (observability never breaks business) + LLMClient throwing = (messages, schema, temperature, maxTokens) -> { + throw new IllegalStateException("llm down"); + }; + assertThrows(IllegalStateException.class, () -> new ObservabilityLLMClient(throwing) + .structured(List.of(), Map.of(), null, null)); + assertNull(LlmUsageStashHolder.getAndClear()); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/NegotiationMetricsTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/NegotiationMetricsTest.java new file mode 100644 index 00000000..98b987d6 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/NegotiationMetricsTest.java @@ -0,0 +1,50 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NegotiationMetricsTest { + + private InMemoryMetricReader reader; + + @BeforeEach + void setUp() { + reader = InMemoryMetricReader.create(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder() + .setMeterProvider( + SdkMeterProvider.builder().registerMetricReader(reader).build()) + .build()); + } + + @AfterEach + void tearDown() { + GlobalOpenTelemetry.resetForTest(); + } + + @Test + void terminalOutcomeRecordsRoundsWithAttributes() { + NegotiationMetrics.reportRounds("N001", "accept", 3); + var metrics = reader.collectAllMetrics(); + var histogram = metrics.stream() + .filter(m -> "a2at.negotiation.total_rounds".equals(m.getName())) + .findFirst() + .orElseThrow(); + assertEquals("a2at.negotiation.total_rounds", histogram.getName()); + assertTrue(!histogram.getHistogramData().getPoints().isEmpty()); + } + + @Test + void nonTerminalOutcomeAndNegativeRoundsAreSkipped() { + NegotiationMetrics.reportRounds("N1", "propose", 2); + NegotiationMetrics.reportRounds("N1", "accept", -1); + assertEquals(0, reader.collectAllMetrics().size()); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityLogsTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityLogsTest.java new file mode 100644 index 00000000..00d64e88 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityLogsTest.java @@ -0,0 +1,94 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +class ObservabilityLogsTest { + + private ListAppender appender; + private Logger logger; + private SdkTracerProvider tracerProvider; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void attach() { + logger = (Logger) LoggerFactory.getLogger("net.openan.a2at.sdk.observability"); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(ch.qos.logback.classic.Level.DEBUG); + GlobalOpenTelemetry.resetForTest(); + tracerProvider = SdkTracerProvider.builder().build(); + GlobalOpenTelemetry.set( + OpenTelemetrySdk.builder().setTracerProvider(tracerProvider).build()); + } + + @AfterEach + void detach() { + logger.detachAppender(appender); + if (tracerProvider != null) { + tracerProvider.close(); + } + GlobalOpenTelemetry.resetForTest(); + System.clearProperty("A2AT_LOG_ENABLED"); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void jsonRecordCarriesEventFieldsAndTraceIds() throws Exception { + try (A2ATSpan span = new A2ATSpan("entry").open()) { + ObservabilityLogs.logEvent( + "task.status_changed", + Level.INFO, + Map.of("task.id", "t1", "task.status", "working"), + null, + A2ATObservabilityConfig.fromEnv()); + } + ILoggingEvent event = appender.list.stream() + .filter(e -> e.getLevel() == ch.qos.logback.classic.Level.INFO) + .findFirst() + .orElseThrow(); + Map json = mapper.readValue(event.getFormattedMessage(), Map.class); + assertEquals("task.status_changed", json.get("event")); + assertEquals("t1", json.get("task.id")); + assertEquals("working", json.get("task.status")); + assertNotNull(json.get("trace_id")); + assertNotNull(json.get("span_id")); + } + + @Test + void payloadChannelHonorsConfigAndRedaction() throws Exception { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv().toBuilder() + .payloadLogEnabled(true) + .payloadRedactor(v -> v.replace("key", "***")) + .build(); + ObservabilityLogs.logEvent("task.request", Level.DEBUG, Map.of(), "payload: key=1", config); + Map json = mapper.readValue(appender.list.get(0).getFormattedMessage(), Map.class); + assertTrue(String.valueOf(json.get("payload")).contains("***")); + assertFalse(String.valueOf(json.get("payload")).contains("key=1")); + } + + @Test + void logSwitchOffEmitsNothing() { + System.setProperty("A2AT_LOG_ENABLED", "false"); + ObservabilityLogs.logEvent("task.request", Level.DEBUG, Map.of(), null, A2ATObservabilityConfig.fromEnv()); + System.clearProperty("A2AT_LOG_ENABLED"); + assertEquals(0, appender.list.size()); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java new file mode 100644 index 00000000..d013a29e --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilityRuntimeTest.java @@ -0,0 +1,85 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.Context; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ObservabilityRuntimeTest { + + @AfterEach + void clearSwitches() { + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + System.clearProperty("A2AT_TRACE_ENABLED"); + GlobalOpenTelemetry.resetForTest(); + ObservabilitySetup.resetForTest(); + } + + @Test + void masterSwitchDisablesAllSignals() { + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + assertFalse(ObservabilityRuntime.isEnabled()); + assertFalse(ObservabilityRuntime.isTraceEnabled()); + assertFalse(ObservabilityRuntime.isMetricEnabled()); + assertFalse(ObservabilityRuntime.isLogEnabled()); + } + + @Test + void traceToggleIsIndependentOfMasterOn() { + System.setProperty("A2AT_TRACE_ENABLED", "false"); + assertTrue(ObservabilityRuntime.isEnabled()); + assertFalse(ObservabilityRuntime.isTraceEnabled()); + assertTrue(ObservabilityRuntime.isMetricEnabled()); + } + + @Test + void sameTraceOrphanContextKeepsTraceIdAndDropsParent() { + SpanContext parent = SpanContext.create( + "0123456789abcdef0123456789abcdef", + "0123456789abcdef", + TraceFlags.getSampled(), + TraceState.getDefault()); + Context orphan = ObservabilityRuntime.sameTraceOrphanContext(parent); + assertNotNull(orphan); + SpanContext orphanSpan = Span.fromContext(orphan).getSpanContext(); + assertEquals(parent.getTraceId(), orphanSpan.getTraceId()); + assertNotEquals(parent.getSpanId(), orphanSpan.getSpanId()); + assertTrue(orphanSpan.isValid()); + } + + @Test + void invalidParentYieldsNull() { + assertNull(ObservabilityRuntime.sameTraceOrphanContext(SpanContext.getInvalid())); + assertNull(ObservabilityRuntime.sameTraceOrphanContext(null)); + } + + @Test + void tracerAndMeterScopeNames() { + assertNotNull(ObservabilityRuntime.tracer()); + assertNotNull(ObservabilityRuntime.meter()); + } + + @Test + void runtimeAccessBeforeGlobalSetDoesNotPoisonSetup() { + GlobalOpenTelemetry.resetForTest(); + ObservabilitySetup.resetForTest(); + assertNotNull(ObservabilityRuntime.tracer()); + TracePropagation.extract(Map.of( + "traceparent", "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01")); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertTrue(ObservabilitySetup.isConfigured()); + assertTrue(GlobalOpenTelemetry.isSet()); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilitySetupTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilitySetupTest.java new file mode 100644 index 00000000..cc62de78 --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ObservabilitySetupTest.java @@ -0,0 +1,54 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ObservabilitySetupTest { + + @BeforeEach + void resetState() { + GlobalOpenTelemetry.resetForTest(); + ObservabilitySetup.resetForTest(); + } + + @AfterEach + void reset() { + GlobalOpenTelemetry.resetForTest(); + ObservabilitySetup.resetForTest(); + System.clearProperty("A2AT_OBSERVABILITY_ENABLED"); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void consoleFallbackConfiguresAllThreeSignals() { + GlobalOpenTelemetry.resetForTest(); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertTrue(ObservabilitySetup.isConfigured()); + assertTrue(GlobalOpenTelemetry.isSet()); + Span span = GlobalOpenTelemetry.getTracer("t").spanBuilder("s").startSpan(); + assertTrue(span.isRecording()); + span.end(); + } + + @Test + void existingGlobalProviderIsRespected() { + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().build()); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertFalse(ObservabilitySetup.isConfigured()); + } + + @Test + void disabledSkipsEverything() { + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertFalse(ObservabilitySetup.isConfigured()); + assertFalse(ObservabilityRuntime.isEnabled()); + } +} diff --git a/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/PropagationAndPayloadTest.java b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/PropagationAndPayloadTest.java new file mode 100644 index 00000000..db68dcaa --- /dev/null +++ b/a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/PropagationAndPayloadTest.java @@ -0,0 +1,81 @@ +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class PropagationAndPayloadTest { + + @AfterEach + void reset() { + GlobalOpenTelemetry.resetForTest(); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void injectExtractRoundtripSurvivesLowercasing() { + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder() + .setTracerProvider(SdkTracerProvider.builder().build()) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build()); + Map headers = new HashMap<>(); + String traceId; + try (var span = new A2ATSpan("root", SpanKind.CLIENT).open()) { + traceId = Span.current().getSpanContext().getTraceId(); + TracePropagation.inject(headers); + } + Map mixed = Map.of("TRACEPARENT", headers.get("traceparent")); + Context extracted = TracePropagation.extract(mixed); + assertNotNull(extracted); + assertEquals(traceId, Span.fromContext(extracted).getSpanContext().getTraceId()); + assertTrue(headers.get("traceparent").startsWith("00-" + traceId)); + } + + @Test + void extractReturnsNullWhenInvalidOrDisabled() { + assertNull(TracePropagation.extract(Map.of("traceparent", "00-abc-def-01"))); + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + assertNull(TracePropagation.extract( + Map.of("traceparent", "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"))); + } + + @Test + void payloadRedactsBeforeTruncation() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv().toBuilder() + .payloadLogMaxLength(10) + .payloadRedactor(value -> value.replace("secret", "***")) + .build(); + String prepared = PayloadSanitizer.prepare("secret-abcdefghijklmnopqrstuvwxyz", config); + assertFalse(prepared.contains("secret")); + assertTrue(prepared.endsWith("[truncated]")); + assertTrue(prepared.length() <= 10 + "[truncated]".length()); + } + + @Test + void payloadExtractorSerializesRecordsAndNullFails() { + record Point(int x, int y) {} + assertTrue(PayloadExtractor.toJson(new Point(1, 2)).contains("\"x\":1")); + assertNull(PayloadExtractor.toJson(null)); + assertNull(PayloadExtractor.toJson(new Object() { + @Override + public String toString() { + throw new IllegalStateException("unserializable"); + } + })); + } +} diff --git a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java index 5dbf3092..d6f77908 100644 --- a/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java +++ b/a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java @@ -5,11 +5,14 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.Supplier; import net.openan.a2at.sdk.core.model.A2ATConfig; import net.openan.a2at.sdk.core.model.FilledParamData; import net.openan.a2at.sdk.core.model.MetadataContent; import net.openan.a2at.sdk.core.model.PromptTemplate; import net.openan.a2at.sdk.core.model.TemplateUri; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; import net.openan.a2at.sdk.core.validation.ContentValidator; import net.openan.a2at.sdk.negotiation.content.NegotiationAbortData; import net.openan.a2at.sdk.negotiation.content.NegotiationEndingData; @@ -24,6 +27,9 @@ import net.openan.a2at.sdk.server.compliance.ServerPromptComplianceOrchestrator; import net.openan.a2at.sdk.server.model.PromptComplianceResult; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * High-level server facade for prompt compliance and negotiation APIs. The caller provides the `.env` file path @@ -33,6 +39,8 @@ */ public final class A2ATServer { + private static final Logger LOGGER = LoggerFactory.getLogger(A2ATServer.class); + private final ServerPromptComplianceOrchestrator promptComplianceOrchestrator; private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; @@ -47,17 +55,31 @@ public final class A2ATServer { private final ContentValidator authContentValidator; + private final TelemetryHook hook; + /** - * Creates a server facade from one user-supplied `.env` path. + * Creates a server facade from one user-supplied `.env` path without telemetry observation. * * @param envPath user-supplied `.env` file path */ public A2ATServer(Path envPath) { + this(envPath, null); + } + + /** + * Creates a server facade from one user-supplied `.env` path with an optional telemetry hook observing every + * facade operation. + * + * @param envPath user-supplied `.env` file path + * @param hook telemetry hook receiving the SDK operation events; {@code null} disables observation + */ + public A2ATServer(Path envPath, @Nullable TelemetryHook hook) { Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); A2ATConfig config = A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); DefaultA2ATServerBuilder builder = DefaultA2ATServerBuilder.builder().envPath(resolvedEnvPath).config(config); + this.hook = hook == null ? TelemetryHook.NO_OP : hook; this.promptComplianceOrchestrator = builder.buildPromptComplianceOrchestrator(); this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); this.negotiationContentService = @@ -68,6 +90,52 @@ public A2ATServer(Path envPath) { this.authContentValidator = builder.buildAuthContentValidator(); } + private T observed(String method, Supplier action) { + TelemetryOperation started; + try { + started = hook.startSdkOperation("server", method); + } catch (RuntimeException error) { + LOGGER.warn("a2at: startSdkOperation failed; telemetry disabled for this call", error); + started = TelemetryOperation.NO_OP; + } + try (TelemetryOperation operation = started) { + try { + T result = action.get(); + try { + operation.markSuccess(); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } + return result; + } catch (RuntimeException error) { + try { + operation.markError(error); + } catch (RuntimeException callbackError) { + LOGGER.warn("a2at: telemetry hook callback failed", callbackError); + } + throw error; + } + } + } + + static @Nullable String terminalOutcome(NegotiationStatus status) { + if (status == NegotiationStatus.AGREED) { + return "accept"; + } + return status == NegotiationStatus.REJECTED ? "reject" : null; + } + + private void reportNegotiationRounds(NegotiationContext context, NegotiationStatus status) { + try { + String outcome = terminalOutcome(status); + if (outcome != null) { + hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round()); + } + } catch (RuntimeException error) { + LOGGER.warn("a2at: negotiation metric report failed", error); + } + } + /** * Checks one processed task prompt for server-side compliance. * @@ -78,7 +146,7 @@ public A2ATServer(Path envPath) { * @return compliance result containing only success state or failure details */ public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptText) { - return promptComplianceOrchestrator.checkTaskPrompt(processedPromptText); + return observed("checkTaskPrompt", () -> promptComplianceOrchestrator.checkTaskPrompt(processedPromptText)); } /** @@ -91,7 +159,7 @@ public PromptComplianceResult checkTaskPrompt(@NonNull String processedPromptTex */ public Map startNegotiation( @NonNull NegotiationType type, @NonNull String contentText, @NonNull Map facts) { - return negotiationOrchestrator.startNegotiation(type, contentText, facts); + return observed("startNegotiation", () -> negotiationOrchestrator.startNegotiation(type, contentText, facts)); } /** @@ -102,7 +170,7 @@ public Map startNegotiation( * @return normalized payload describing the receive result */ public Map receiveNegotiation(@NonNull String message, @NonNull Map context) { - return negotiationOrchestrator.receiveNegotiation(message, context); + return observed("receiveNegotiation", () -> negotiationOrchestrator.receiveNegotiation(message, context)); } /** @@ -115,7 +183,11 @@ public Map receiveNegotiation(@NonNull String message, @NonNull */ public Map continueNegotiation( @NonNull NegotiationContext context, @NonNull NegotiationStatus status, @NonNull String contentText) { - return negotiationOrchestrator.continueNegotiation(context, status, contentText); + return observed("continueNegotiation", () -> { + Map result = negotiationOrchestrator.continueNegotiation(context, status, contentText); + reportNegotiationRounds(context, status); + return result; + }); } /** @@ -139,7 +211,9 @@ public Map continueNegotiation( */ public MetadataContent generateNegotiationProposePromptFromData( @NonNull NegotiationProposeData data, @NonNull String templateUri) { - return negotiationContentService.generateProposeFromData(data, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationProposePromptFromData", + () -> negotiationContentService.generateProposeFromData(data, parseTemplateUri(templateUri))); } /** @@ -163,7 +237,9 @@ public MetadataContent generateNegotiationProposePromptFromData( */ public MetadataContent generateNegotiationAcceptPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { - return negotiationContentService.generateAcceptFromData(data, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationAcceptPromptFromData", + () -> negotiationContentService.generateAcceptFromData(data, parseTemplateUri(templateUri))); } /** @@ -187,7 +263,9 @@ public MetadataContent generateNegotiationAcceptPromptFromData( */ public MetadataContent generateNegotiationRejectPromptFromData( @NonNull NegotiationEndingData data, @NonNull String templateUri) { - return negotiationContentService.generateRejectFromData(data, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationRejectPromptFromData", + () -> negotiationContentService.generateRejectFromData(data, parseTemplateUri(templateUri))); } /** @@ -209,7 +287,9 @@ public MetadataContent generateNegotiationRejectPromptFromData( */ public MetadataContent generateNegotiationAbortPromptFromData( @NonNull NegotiationAbortData data, @NonNull String templateUri) { - return negotiationContentService.generateAbortFromData(data, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationAbortPromptFromData", + () -> negotiationContentService.generateAbortFromData(data, parseTemplateUri(templateUri))); } /** @@ -240,7 +320,9 @@ public MetadataContent generateNegotiationProposePromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - return negotiationContentService.generateProposeFromText(text, context, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationProposePromptFromText", + () -> negotiationContentService.generateProposeFromText(text, context, parseTemplateUri(templateUri))); } /** @@ -271,7 +353,9 @@ public MetadataContent generateNegotiationAcceptPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - return negotiationContentService.generateAcceptFromText(text, context, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationAcceptPromptFromText", + () -> negotiationContentService.generateAcceptFromText(text, context, parseTemplateUri(templateUri))); } /** @@ -302,7 +386,9 @@ public MetadataContent generateNegotiationRejectPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - return negotiationContentService.generateRejectFromText(text, context, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationRejectPromptFromText", + () -> negotiationContentService.generateRejectFromText(text, context, parseTemplateUri(templateUri))); } /** @@ -331,7 +417,9 @@ public MetadataContent generateNegotiationAbortPromptFromText( @NonNull String text, net.openan.a2at.sdk.core.model.@NonNull NegotiationContext context, @NonNull String templateUri) { - return negotiationContentService.generateAbortFromText(text, context, parseTemplateUri(templateUri)); + return observed( + "generateNegotiationAbortPromptFromText", + () -> negotiationContentService.generateAbortFromText(text, context, parseTemplateUri(templateUri))); } /** @@ -345,7 +433,7 @@ public MetadataContent generateNegotiationAbortPromptFromText( * none can be loaded */ public List getPrompts() { - return templateQueryService.getPrompts(); + return observed("getPrompts", () -> templateQueryService.getPrompts()); } /** @@ -361,7 +449,7 @@ public List getPrompts() { * @throws IllegalArgumentException if the template URI is blank or malformed */ public Optional getPrompt(@NonNull String templateUri) { - return templateQueryService.getPrompt(parseTemplateUri(templateUri)); + return observed("getPrompt", () -> templateQueryService.getPrompt(parseTemplateUri(templateUri))); } /** @@ -397,8 +485,10 @@ public FilledParamData validateProposePromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - return negotiationContentService.validateProposePromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); + return observed( + "validateProposePromptAndDataFilling", + () -> negotiationContentService.validateProposePromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); } /** @@ -434,8 +524,10 @@ public FilledParamData validateAcceptPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - return negotiationContentService.validateAcceptPromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); + return observed( + "validateAcceptPromptAndDataFilling", + () -> negotiationContentService.validateAcceptPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); } /** @@ -471,8 +563,10 @@ public FilledParamData validateRejectPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - return negotiationContentService.validateRejectPromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); + return observed( + "validateRejectPromptAndDataFilling", + () -> negotiationContentService.validateRejectPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); } /** @@ -508,8 +602,10 @@ public FilledParamData validateAbortPromptAndDataFilling( net.openan.a2at.sdk.core.model.NegotiationContext context, @NonNull Map schema, @NonNull String templateUri) { - return negotiationContentService.validateAbortPromptAndDataFilling( - prompt, context, schema, parseTemplateUri(templateUri)); + return observed( + "validateAbortPromptAndDataFilling", + () -> negotiationContentService.validateAbortPromptAndDataFilling( + prompt, context, schema, parseTemplateUri(templateUri))); } /** @@ -533,7 +629,9 @@ public FilledParamData validateAbortPromptAndDataFilling( */ public FilledParamData validateTaskPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { - return taskContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + return observed( + "validateTaskPromptAndDataFilling", + () -> taskContentValidator.validate(prompt, schema, parseTemplateUri(templateUri))); } /** @@ -557,7 +655,9 @@ public FilledParamData validateTaskPromptAndDataFilling( */ public FilledParamData validateNotificationPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { - return notificationContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + return observed( + "validateNotificationPromptAndDataFilling", + () -> notificationContentValidator.validate(prompt, schema, parseTemplateUri(templateUri))); } /** @@ -581,7 +681,9 @@ public FilledParamData validateNotificationPromptAndDataFilling( */ public FilledParamData validateAuthPromptAndDataFilling( @NonNull String prompt, @NonNull Map schema, @NonNull String templateUri) { - return authContentValidator.validate(prompt, schema, parseTemplateUri(templateUri)); + return observed( + "validateAuthPromptAndDataFilling", + () -> authContentValidator.validate(prompt, schema, parseTemplateUri(templateUri))); } private static TemplateUri parseTemplateUri(String templateUri) { diff --git a/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java new file mode 100644 index 00000000..58a20e16 --- /dev/null +++ b/a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java @@ -0,0 +1,166 @@ +package net.openan.a2at.sdk.server; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientConfig; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class A2ATServerObservabilityTest { + + private static final String MOCK_PROVIDER = "a2at-observability-mock"; + + public static final class NoopLLM implements LLMClient { + public NoopLLM(LLMClientConfig config) {} + + @Override + public LLMResponse structured( + List> messages, Map schema, Double temperature, Integer maxTokens) { + return new LLMResponse("{}", "mock", Map.of(), Map.of()); + } + } + + static final class RecordingHook implements TelemetryHook { + final List started = new ArrayList<>(); + final List succeeded = new ArrayList<>(); + final List rounds = new ArrayList<>(); + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + String name = role + "." + method; + started.add(name); + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() { + succeeded.add(name); + } + + @Override public void markError(Throwable error) { + started.add("FAILED:" + name); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + } + + static final class ThrowingHook implements TelemetryHook { + private final boolean throwOnStart; + + ThrowingHook(boolean throwOnStart) { + this.throwOnStart = throwOnStart; + } + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + if (throwOnStart) { + throw new IllegalStateException("startSdkOperation failed"); + } + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + + @Override public void markSuccess() {} + + @Override public void markError(Throwable error) { + throw new IllegalStateException("markError failed"); + } + + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + } + + @TempDir + Path tempDir; + + private A2ATServer newServer(TelemetryHook hook) throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + return new A2ATServer(writeEnv(hook == null), hook); + } + + private Path writeEnv(boolean legacy) throws IOException { + Path envFile = tempDir.resolve(legacy ? "server-legacy.env" : "server.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_PROMPT_SOURCE_TYPE=classpath + A2AT_PROMPT_RESOURCE_LOCAL_ROOT_DIR= + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + A2AT_PROMPT_COMPLIANCE_ENABLED=false + """ + .formatted(MOCK_PROVIDER)); + return envFile; + } + + @Test + void llmFreePathIsWrappedAsSdkOperation() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATServer server = newServer(hook); + server.getPrompts(); + assertTrue(hook.started.contains("server.getPrompts")); + assertTrue(hook.succeeded.contains("server.getPrompts")); + } + + @Test + void runtimeFailureMarksErrorAndPropagates() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATServer server = newServer(hook); + assertThrows(NullPointerException.class, () -> server.getPrompt(null)); + assertTrue(hook.started.contains("server.getPrompt")); + assertTrue(hook.started.contains("FAILED:server.getPrompt")); + } + + @Test + void throwingHookDoesNotBreakBusinessFlow() throws IOException { + A2ATServer startFailure = newServer(new ThrowingHook(true)); + assertDoesNotThrow(startFailure::getPrompts); + + A2ATServer markErrorFailure = newServer(new ThrowingHook(false)); + NullPointerException error = + assertThrows(NullPointerException.class, () -> markErrorFailure.getPrompt(null)); + assertEquals("templateUri", error.getMessage()); + } + + @Test + void legacyConstructorAndFacadeCallsComplete() throws IOException { + A2ATServer server = new A2ATServer(writeEnv(true)); + assertDoesNotThrow(server::getPrompts); + assertDoesNotThrow(server::getPrompts); + } + + @Test + void terminalOutcomeMapping() { + assertEquals("accept", A2ATServer.terminalOutcome(NegotiationStatus.AGREED)); + assertEquals("reject", A2ATServer.terminalOutcome(NegotiationStatus.REJECTED)); + assertEquals(null, A2ATServer.terminalOutcome(NegotiationStatus.IN_PROGRESS)); + } +} diff --git a/docs/superpowers/plans/2026-10-20-observability-core.md b/docs/superpowers/plans/2026-10-20-observability-core.md new file mode 100644 index 00000000..c8999d8d --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-observability-core.md @@ -0,0 +1,2898 @@ +# A2A-T Java SDK 可观测性(核心模块 + SPI + 门面接线)实施计划 — Plan A + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 按 `docs/superpowers/specs/2026-10-20-observability-java-port-design.md` 落地 `a2a-t-observability` 核心模块、`TelemetryHook` SPI 与门面接线(Plan B:a2a-java 协议层装饰器,单独计划,待本计划落地后编写)。 + +**Architecture:** 双新模块(core 观测 + a2a 协议层);core 定义 `TelemetryHook` SPI,门面二级构造器注入 `@Nullable` hook 包装全部公开方法;OTel api 编译依赖 + sdk/exporter 可选;全同步 SDK,ThreadLocal 作 LLM 用量 stash。 + +**Tech Stack:** Java 17 / Maven 多模块 / OpenTelemetry 1.64.0(api 编译 + sdk、exporter-otlp ``;`exporter-logging` 不在本地 .m2 → Console 降级为内置 SLF4J JSON 导出器)/ JUnit 5(无 Mockito)/ SLF4J + Jackson。 + +## Global Constraints + +- 构建/测试一律在仓库根目录:`mvn -q -pl -am test`;提交前 `mvn -q spotless:apply`(Palantir 格式强制)。 +- 每个新包必须有 `package-info.java` 且 `@NullMarked`(JSpecify);可空参数/字段用 `org.jspecify.annotations.@Nullable`(NullAway ERROR)。 +- 新代码不用 Lombok、不用 Mockito;测试用 JUnit 5 + 手写 stub + logback `ListAppender`。 +- OTel 版本 1.64.0(root pom 属性 + bom import);a2a-java 1.1.0.Final。 +- 观测失败永不破坏业务:插桩点 catch RuntimeException → WARN(logger `net.openan.a2at.sdk.observability`)→ 吞掉;业务异常原样抛。 +- 命名注册表与 spec §5 逐字一致;环境变量与 spec §7 一致。 +- 开关读取顺序(全部键):`System.getProperty(key)` → `System.getenv(key)` → 默认值(比 Python 多一级 getProperty,便于测试覆盖;行为等价)。 +- 提交信息 conventional commits(`feat(observability): ...`)。 + +## File Structure(Plan A 产出) + +``` +pom.xml (改: modules + 属性 + otel bom import + a2a-java 版本管理) +a2a-t-bom/pom.xml (改: +2 模块) +a2a-t-observability/pom.xml (新) +a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/ + package-info.java, A2ATObservabilityConfig.java, A2ATAttributes.java, + AttributeExtractors.java, EventInfo.java, ObservabilityRuntime.java, + A2ATSpan.java, A2ATCurrentSpan.java, CurrentSpans.java, TracePropagation.java, + PayloadExtractor.java, PayloadSanitizer.java, LlmUsageStash.java, LlmUsageStashHolder.java, + ObservabilityLLMClient.java, MetricHistograms.java, NegotiationMetrics.java, + ObservabilityLogs.java, ConsoleExporters.java, ObservabilitySetup.java +a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/ (镜像测试) +a2a-t-observability-a2a/pom.xml (新: 骨架,Plan B 填充) +a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/ + package-info.java, TelemetryHook.java, TelemetryOperation.java +a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java (改: hook 接线) +a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java (改: hook 接线) +env.example (改: 可观测性小节) +``` + +> spec §3 的 attributes.py 在 Java 拆分为两半:core 模块放常量 + Map 级提取器 + `EventInfo`;a2a-java 类型化分类器(`StreamingEventKind`/`MessageSendParams` instanceof 分派)放 a2a 模块(Plan B)——依赖方向要求。 + +--- + +### Task 1: 模块骨架与依赖登记 + +**Files:** +- Modify: `pom.xml`(modules、properties、dependencyManagement) +- Modify: `a2a-t-bom/pom.xml` +- Create: `a2a-t-observability/pom.xml`、`a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/package-info.java` +- Create: `a2a-t-observability-a2a/pom.xml`、`a2a-t-observability-a2a/src/main/java/net/openan/a2at/sdk/observability/a2a/package-info.java` + +**Interfaces:** +- Produces: 两个可编译空模块;root pom 属性 `opentelemetry.version=1.64.0`、`a2a-java.version=1.1.0.Final`;后续任务靠 root `dependencyManagement` 省略版本号。 + +- [ ] **Step 1: 根 pom 三处修改** + +`` 中 `a2a-t-server` 之后追加: + +```xml + a2a-t-observability + a2a-t-observability-a2a +``` + +`` 中 `2.0.17` 之后追加: + +```xml + 1.64.0 + 1.1.0.Final +``` + +`` 中 `jspecify` 条目之后追加: + +```xml + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-spi + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-spec + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-server-common + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-jsonrpc-common + ${a2a-java.version} + + + org.a2aproject.sdk + a2a-java-sdk-transport-rest + ${a2a-java.version} + +``` + +- [ ] **Step 2: BOM 登记** — `a2a-t-bom/pom.xml` 的 `a2a-t-server` 条目后追加: + +```xml + + net.openan.a2a-t.sdk + a2a-t-observability + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-observability-a2a + ${project.version} + +``` + +- [ ] **Step 3: 核心模块 pom** — `a2a-t-observability/pom.xml`(父 POM + ${project.version} 依赖,模板同 `a2a-t-llm/pom.xml`): + +```xml + + 4.0.0 + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + a2a-t-observability + a2a-t Observability + Observability core for the a2a-t Java SDK: config, setup, manual span API, propagation, logs, metrics, LLM decorator. + + + net.openan.a2a-t.sdk + a2a-t-core + ${project.version} + + + net.openan.a2a-t.sdk + a2a-t-llm + ${project.version} + + + io.opentelemetry + opentelemetry-api + + + io.opentelemetry + opentelemetry-sdk + true + + + io.opentelemetry + opentelemetry-exporter-otlp + true + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + test + + + ch.qos.logback + logback-classic + 1.4.14 + test + + + io.opentelemetry + opentelemetry-sdk-testing + test + + + +``` + +(`opentelemetry-sdk-testing` 经 bom 管理 1.64.0,提供 `InMemorySpanExporter`/`InMemoryMetricReader`,测试基建统一走它。) + +- [ ] **Step 4: a2a 模块骨架 pom** — `a2a-t-observability-a2a/pom.xml`: + +```xml + + 4.0.0 + + net.openan.a2a-t.sdk + a2a-t-parent + ${revision} + + a2a-t-observability-a2a + a2a-t Observability A2A + Protocol-level observability decorators for a2a-java (transport, request handler, executor, push sender). Filled by the observability-a2a plan. + + + net.openan.a2a-t.sdk + a2a-t-observability + ${project.version} + + + org.a2aproject.sdk + a2a-java-sdk-client-transport-spi + + + org.a2aproject.sdk + a2a-java-sdk-spec + + + org.a2aproject.sdk + a2a-java-sdk-server-common + + + org.a2aproject.sdk + a2a-java-sdk-jsonrpc-common + + + org.a2aproject.sdk + a2a-java-sdk-transport-rest + + + org.junit.jupiter + junit-jupiter + test + + + +``` + +- [ ] **Step 5: package-info** — 两个文件,内容模式一致: + +```java +/** Observability core for the a2a-t Java SDK. */ +@NullMarked +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.NullMarked; +``` + +(a2a 模块:`package net.openan.a2at.sdk.observability.a2a;`,注释 `/** Protocol-level observability decorators for a2a-java. */`) + +- [ ] **Step 6: 验证编译** — 根目录运行 `mvn -q -pl a2a-t-observability,a2a-t-observability-a2a -am compile`,期望 BUILD SUCCESS(无 OTel/“a2a-java 缺失”报错)。 + +- [ ] **Step 7: 提交** — `mvn -q spotless:apply; git add pom.xml a2a-t-bom/pom.xml a2a-t-observability a2a-t-observability-a2a; git commit -m "feat(observability): add observability module skeletons and dependency registration"` + +### Task 2: TelemetryHook SPI(a2a-t-core) + +**Files:** +- Create: `a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/package-info.java` +- Create: `a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryOperation.java` +- Create: `a2a-t-core/src/main/java/net/openan/a2at/sdk/core/telemetry/TelemetryHook.java` +- Test: `a2a-t-core/src/test/java/net/openan/a2at/sdk/core/telemetry/TelemetryHookTest.java` + +**Interfaces:** +- Produces: `TelemetryHook.startSdkOperation(String role, String method) -> TelemetryOperation`、`TelemetryHook.reportNegotiationRounds(String negotiationId, String outcome, long totalRounds)`、`TelemetryHook.NO_OP`;`TelemetryOperation`(AutoCloseable)`setAttribute(String, Object)` / `markSuccess()` / `markError(Throwable)` / `close()` / `TelemetryOperation.NO_OP`。Task 10 门面与 Plan B `SdkOperationSpans` 依赖此面。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.core.telemetry; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class TelemetryHookTest { + + @Test + void noOpHookAbsorbsEverything() { + TelemetryOperation operation = TelemetryHook.NO_OP.startSdkOperation("client", "getPrompts"); + assertSame(TelemetryOperation.NO_OP, operation); + assertDoesNotThrow(() -> { + operation.setAttribute("k", "v"); + operation.markSuccess(); + operation.markError(new RuntimeException("x")); + operation.close(); + TelemetryHook.NO_OP.reportNegotiationRounds("n1", "accept", 3); + }); + } + + @Test + void customHookReceivesCalls() { + List started = new ArrayList<>(); + List rounds = new ArrayList<>(); + TelemetryHook hook = new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + started.add(role + "." + method); + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + }; + hook.startSdkOperation("server", "checkTaskPrompt").close(); + hook.reportNegotiationRounds("N1", "reject", 2); + assertTrue(started.contains("server.checkTaskPrompt")); + assertTrue(rounds.contains("N1:reject:2")); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-core test "-Dtest=TelemetryHookTest"`,期望编译错误(类不存在)。 + +- [ ] **Step 3: 实现** + +`TelemetryOperation.java`: + +```java +package net.openan.a2at.sdk.core.telemetry; + +/** One observed SDK operation; AutoCloseable for try-with-resources. */ +public interface TelemetryOperation extends AutoCloseable { + + /** Attaches one attribute to the underlying observation. No-op values are ignored. */ + void setAttribute(String key, Object value); + + /** Marks the operation as business-successful before close. */ + void markSuccess(); + + /** Marks the operation as failed; the Throwable is recorded verbatim. */ + void markError(Throwable error); + + /** Ends the operation. Idempotent. */ + @Override + void close(); + + TelemetryOperation NO_OP = + new TelemetryOperation() { + @Override + public void setAttribute(String key, Object value) {} + + @Override + public void markSuccess() {} + + @Override + public void markError(Throwable error) {} + + @Override + public void close() {} + }; +} +``` + +`TelemetryHook.java`: + +```java +package net.openan.a2at.sdk.core.telemetry; + +/** + * Telemetry SPI consumed by SDK facades. Implementations live in optional observability modules; + * the default {@link #NO_OP} keeps the core dependency-free. + */ +public interface TelemetryHook { + + /** Starts one facade-operation observation named {@code a2at.sdk..}. */ + TelemetryOperation startSdkOperation(String role, String method); + + /** Reports one terminal negotiation round count (outcome: accept/reject). */ + void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds); + + TelemetryHook NO_OP = + new TelemetryHook() { + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + return TelemetryOperation.NO_OP; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) {} + }; +} +``` + +`package-info.java`:`@NullMarked` 模式,注释 `/** Telemetry SPI for the a2a-t Java SDK core. */`。 + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-core test "-Dtest=TelemetryHookTest"`,期望 PASS。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-core; git commit -m "feat(observability): add TelemetryHook SPI to core"` + +--- + +### Task 3: A2ATObservabilityConfig + +**Files:** +- Create: `a2a-t-observability/src/main/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfig.java` +- Test: `a2a-t-observability/src/test/java/net/openan/a2at/sdk/observability/A2ATObservabilityConfigTest.java` + +**Interfaces:** +- Produces: record 15 字段(顺序即下码)+ `fromEnv()` / `fromMap(Map)` / `builder()`;布尔键解析 truthy 集合 `{1,true,yes,on}`(大小写不敏感,其余 false);协议别名 `http` → `http/protobuf`,非法 → `grpc`;`A2AT_SERVICE_NAME` 优先于 `OTEL_SERVICE_NAME`;正则字段仅编程式设置(无 env 键),空串 = opt-out、null = 用内置默认(消费者决定)。Task 4/8/9 与 Plan B 全部消费。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class A2ATObservabilityConfigTest { + + @Test + void defaultsMatchSpec() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv(); + assertTrue(config.enabled()); + assertTrue(config.traceEnabled()); + assertTrue(config.metricEnabled()); + assertTrue(config.logEnabled()); + assertEquals("grpc", config.protocol()); + assertEquals(A2ATObservabilityConfig.DEFAULT_PAYLOAD_LOG_MAX_LENGTH, config.payloadLogMaxLength()); + assertFalse(config.extractRequest()); + assertFalse(config.extractResponse()); + assertFalse(config.payloadLogEnabled()); + } + + @Test + void mapValuesBeatDefaults() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of( + "A2AT_OBSERVABILITY_ENABLED", "false", + "A2AT_TRACE_ENABLED", "false", + "OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4318", + "A2AT_EXPORTER_PROTOCOL", "http", + "A2AT_SERVICE_NAME", "svc", + "A2AT_LOGS_PAYLOAD_ENABLED", "TRUE", + "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "128")); + assertFalse(config.enabled()); + assertFalse(config.traceEnabled()); + assertTrue(config.metricEnabled()); + assertEquals("http/protobuf", config.protocol()); + assertEquals("http://collector:4318", config.endpoint()); + assertEquals("svc", config.serviceName()); + assertTrue(config.payloadLogEnabled()); + assertEquals(128, config.payloadLogMaxLength()); + } + + @Test + void invalidProtocolFallsBackToGrpcAndInvalidIntFallsBackToDefault() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of( + "A2AT_EXPORTER_PROTOCOL", "udp", "A2AT_LOGS_PAYLOAD_MAX_LENGTH", "abc")); + assertEquals("grpc", config.protocol()); + assertEquals(4096, config.payloadLogMaxLength()); + } + + @Test + void builderBeatsMap() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromMap(Map.of("A2AT_SERVICE_NAME", "from-map")) + .toBuilder() + .serviceName("explicit") + .taskTypeRegex("") + .build(); + assertEquals("explicit", config.serviceName()); + assertEquals("", config.taskTypeRegex()); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATObservabilityConfigTest"`(编译错误)。 + +- [ ] **Step 3: 实现** — `A2ATObservabilityConfig.java`: + +```java +package net.openan.a2at.sdk.observability; + +import java.util.Locale; +import java.util.Map; +import java.util.function.UnaryOperator; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Observability configuration: explicit builder > .env map > system env > spec defaults. */ +public record A2ATObservabilityConfig( + boolean enabled, + boolean traceEnabled, + boolean metricEnabled, + boolean logEnabled, + @Nullable String endpoint, + String protocol, + @Nullable String serviceName, + @Nullable String taskTypeRegex, + @Nullable String notificationTopicRegex, + @Nullable String authorizationPolicyOperationTypeRegex, + boolean extractRequest, + boolean extractResponse, + boolean payloadLogEnabled, + int payloadLogMaxLength, + @Nullable UnaryOperator payloadRedactor) { + + private static final Logger LOG = LoggerFactory.getLogger(A2ATObservabilityConfig.class); + + public static final String ENV_ENABLED = "A2AT_OBSERVABILITY_ENABLED"; + public static final String ENV_TRACE = "A2AT_TRACE_ENABLED"; + public static final String ENV_METRIC = "A2AT_METRIC_ENABLED"; + public static final String ENV_LOG = "A2AT_LOG_ENABLED"; + public static final String ENV_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"; + public static final String ENV_PROTOCOL = "A2AT_EXPORTER_PROTOCOL"; + public static final String ENV_SERVICE_NAME = "A2AT_SERVICE_NAME"; + public static final String ENV_OTEL_SERVICE_NAME = "OTEL_SERVICE_NAME"; + public static final String ENV_AGENT_ROLE = "A2AT_AGENT_ROLE"; + public static final String ENV_EXTRACT_REQUEST = "A2AT_EXTRACT_REQUEST"; + public static final String ENV_EXTRACT_RESPONSE = "A2AT_EXTRACT_RESPONSE"; + public static final String ENV_PAYLOAD_LOG_ENABLED = "A2AT_LOGS_PAYLOAD_ENABLED"; + public static final String ENV_PAYLOAD_LOG_MAX_LENGTH = "A2AT_LOGS_PAYLOAD_MAX_LENGTH"; + public static final String ENV_CONSOLE_EXPORTER = "A2AT_CONSOLE_EXPORTER"; + + public static final String DEFAULT_PROTOCOL = "grpc"; + public static final int DEFAULT_PAYLOAD_LOG_MAX_LENGTH = 4096; + public static final String DEFAULT_SERVICE_NAME = "a2a-t-agent"; + + public static A2ATObservabilityConfig fromEnv() { + return fromMap(Map.of()); + } + + /** @param values .env file values (take precedence over system env); missing keys fall back to env then defaults */ + public static A2ATObservabilityConfig fromMap(Map values) { + Builder b = new Builder(); + b.enabled = bool(values, ENV_ENABLED, true); + b.traceEnabled = bool(values, ENV_TRACE, true); + b.metricEnabled = bool(values, ENV_METRIC, true); + b.logEnabled = bool(values, ENV_LOG, true); + b.endpoint = str(values, ENV_ENDPOINT); + b.protocol = normalizeProtocol(str(values, ENV_PROTOCOL)); + String fromSdkKey = str(values, ENV_SERVICE_NAME); + b.serviceName = fromSdkKey != null ? fromSdkKey : str(values, ENV_OTEL_SERVICE_NAME); + b.extractRequest = bool(values, ENV_EXTRACT_REQUEST, false); + b.extractResponse = bool(values, ENV_EXTRACT_RESPONSE, false); + b.payloadLogEnabled = bool(values, ENV_PAYLOAD_LOG_ENABLED, false); + b.payloadLogMaxLength = intOr(values, ENV_PAYLOAD_LOG_MAX_LENGTH, DEFAULT_PAYLOAD_LOG_MAX_LENGTH); + return b.build(); + } + + public static Builder builder() { + return new Builder(); + } + + public Builder toBuilder() { + Builder b = new Builder(); + b.enabled = enabled; + b.traceEnabled = traceEnabled; + b.metricEnabled = metricEnabled; + b.logEnabled = logEnabled; + b.endpoint = endpoint; + b.protocol = protocol; + b.serviceName = serviceName; + b.agentRole = resolveAgentRole(); + b.taskTypeRegex = taskTypeRegex; + b.notificationTopicRegex = notificationTopicRegex; + b.authorizationPolicyOperationTypeRegex = authorizationPolicyOperationTypeRegex; + b.extractRequest = extractRequest; + b.extractResponse = extractResponse; + b.payloadLogEnabled = payloadLogEnabled; + b.payloadLogMaxLength = payloadLogMaxLength; + b.payloadRedactor = payloadRedactor; + return b; + } + + /** Agent role: env-only opt-in (no builder default). */ + public @Nullable String resolveAgentRole() { + String role = System.getProperty(ENV_AGENT_ROLE, System.getenv(ENV_AGENT_ROLE)); + return role == null || role.isBlank() ? null : role.trim(); + } + + static String normalizeProtocol(@Nullable String raw) { + String value = (raw == null ? DEFAULT_PROTOCOL : raw).trim().toLowerCase(Locale.ROOT); + if (value.equals("grpc")) { + return "grpc"; + } + if (value.equals("http") || value.equals("http/protobuf")) { + return "http/protobuf"; + } + LOG.warn("a2at: invalid exporter protocol {}; falling back to grpc", raw); + return DEFAULT_PROTOCOL; + } + + private static @Nullable String str(Map values, String key) { + String v = values.get(key); + if (v != null && !v.isBlank()) { + return v.trim(); + } + String env = System.getenv(key); + return env == null || env.isBlank() ? null : env.trim(); + } + + private static boolean bool(Map values, String key, boolean def) { + String v = str(values, key); + if (v == null) { + return def; + } + return switch (v.toLowerCase(Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + private static int intOr(Map values, String key, int def) { + String v = str(values, key); + if (v == null) { + return def; + } + try { + return Integer.parseInt(v); + } catch (NumberFormatException e) { + return def; + } + } + + public static final class Builder { + private boolean enabled = true; + private boolean traceEnabled = true; + private boolean metricEnabled = true; + private boolean logEnabled = true; + private @Nullable String endpoint; + private String protocol = DEFAULT_PROTOCOL; + private @Nullable String serviceName; + private @Nullable String agentRole; + private @Nullable String taskTypeRegex; + private @Nullable String notificationTopicRegex; + private @Nullable String authorizationPolicyOperationTypeRegex; + private boolean extractRequest; + private boolean extractResponse; + private boolean payloadLogEnabled; + private int payloadLogMaxLength = DEFAULT_PAYLOAD_LOG_MAX_LENGTH; + private @Nullable UnaryOperator payloadRedactor; + + public Builder enabled(boolean v) { this.enabled = v; return this; } + public Builder traceEnabled(boolean v) { this.traceEnabled = v; return this; } + public Builder metricEnabled(boolean v) { this.metricEnabled = v; return this; } + public Builder logEnabled(boolean v) { this.logEnabled = v; return this; } + public Builder endpoint(@Nullable String v) { this.endpoint = v; return this; } + public Builder protocol(String v) { this.protocol = normalizeProtocol(v); return this; } + public Builder serviceName(@Nullable String v) { this.serviceName = v; return this; } + public Builder agentRole(@Nullable String v) { this.agentRole = v; return this; } + public Builder taskTypeRegex(@Nullable String v) { this.taskTypeRegex = v; return this; } + public Builder notificationTopicRegex(@Nullable String v) { this.notificationTopicRegex = v; return this; } + public Builder authorizationPolicyOperationTypeRegex(@Nullable String v) { this.authorizationPolicyOperationTypeRegex = v; return this; } + public Builder extractRequest(boolean v) { this.extractRequest = v; return this; } + public Builder extractResponse(boolean v) { this.extractResponse = v; return this; } + public Builder payloadLogEnabled(boolean v) { this.payloadLogEnabled = v; return this; } + public Builder payloadLogMaxLength(int v) { this.payloadLogMaxLength = v; return this; } + public Builder payloadRedactor(@Nullable UnaryOperator v) { this.payloadRedactor = v; return this; } + + public A2ATObservabilityConfig build() { + if (payloadLogMaxLength <= 0) { + throw new IllegalArgumentException("payloadLogMaxLength must be positive"); + } + String name = serviceName != null ? serviceName : System.getenv(ENV_SERVICE_NAME); + if (name == null || name.isBlank()) { + name = System.getenv(ENV_OTEL_SERVICE_NAME); + } + return new A2ATObservabilityConfig( + enabled, traceEnabled, metricEnabled, logEnabled, endpoint, protocol, + name == null || name.isBlank() ? DEFAULT_SERVICE_NAME : name.trim(), + agentRole, taskTypeRegex, notificationTopicRegex, authorizationPolicyOperationTypeRegex, + extractRequest, extractResponse, payloadLogEnabled, payloadLogMaxLength, payloadRedactor); + } + } +} +``` + +注意:record 显式包含 `agentRole` 字段——在头部 15 字段列表中补上(`serviceName` 之后),构造器调用相应对齐。 + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATObservabilityConfigTest"`,期望 PASS。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add A2ATObservabilityConfig"` + +--- + +### Task 4: A2ATAttributes + EventInfo + AttributeExtractors + +**Files:** +- Create: `.../observability/A2ATAttributes.java`、`.../observability/EventInfo.java`、`.../observability/AttributeExtractors.java` +- Test: `.../observability/AttributeExtractorsTest.java` + +**Interfaces:** +- Produces(后续所有任务与 Plan B 消费): + - `A2ATAttributes`:全部属性常量(spec §5.2 逐字值) + - `EventInfo(String kind, @Nullable String taskId, @Nullable String taskStatus, boolean finalEvent, boolean terminal, @Nullable Map messageMetadata, @Nullable String artifactName)` + `EventInfo.UNKNOWN` + - `AttributeExtractors.extensionNameFromUri(@Nullable String)`、`extensionNameFromMetadata(Map)`、`negotiationAttributes(Map)`、`isNegotiationMessage(Message)`、`normalizeTaskState(@Nullable TaskState)`、`classifyEvent(StreamingEventKind)`、`requestAttributes(String operationName, @Nullable Object params)`、`messageDoubleAttributes(Message, boolean clientSide)`、`regexAttributes(Map, A2ATObservabilityConfig)` + - 常量 `NEGOTIATION_EXTENSION = "Negotiation-T"`、`NEGOTIATION_SUFFIX = "-negotiation"`、`EVENT_LOG_KINDS` + +> 类型化提取器(`Message`/`StreamingEventKind`/`MessageSendParams` 参数)**不引 a2a-java 依赖就无法编译**——但 a2a-java 不在本模块依赖里。按 spec §3 的 Java 拆分:本任务只落 **Map 级**部分(extension/negotiation/regex/常量/EventInfo);`classifyEvent`/`requestAttributes`/`messageDoubleAttributes` 等类型化分派放 **a2a 模块**(Plan B 的 `A2AJavaAttributeExtractors`)。本任务接口因此收窄为: + +- `extensionNameFromUri(@Nullable String uri) -> @Nullable String` +- `extensionNameFromMetadata(Map metadata) -> @Nullable String` +- `isNegotiationMessage(Map metadata) -> boolean`(metadata 键含 Negotiation-T 扩展 URI) +- `negotiationAttributes(Map metadata) -> Map`(读 `negotiationContext.id/round/maxRounds/performative`,终态 performative 附 `total_rounds`) +- `normalizeTaskStateName(@Nullable String enumName) -> @Nullable String`(`TASK_STATE_COMPLETED` → `completed`) +- `regexAttributes(Map metadata, A2ATObservabilityConfig config) -> Map`(null regex → DEFAULT 回退、空串 opt-out、尾括号清洗) + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AttributeExtractorsTest { + + private static final String NEGOTIATION_URI = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Negotiation-T"; + + @Test + void extensionNameFromUriParsesBase() { + assertEquals("Negotiation-T", AttributeExtractors.extensionNameFromUri(NEGOTIATION_URI)); + assertEquals("Task-T", AttributeExtractors.extensionNameFromUri( + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/Task-T/sub")); + assertNull(AttributeExtractors.extensionNameFromUri("https://other.example/ext")); + assertNull(AttributeExtractors.extensionNameFromUri(null)); + } + + @Test + void negotiationAttributesExtractedFromMetadata() { + Map metadata = Map.of(NEGOTIATION_URI, "v1", "negotiationContext", + Map.of("id", "N001", "round", 3, "maxRounds", 5, "performative", "ACCEPT")); + Map attrs = AttributeExtractors.negotiationAttributes(metadata); + assertEquals("N001", attrs.get(A2ATAttributes.ATTR_NEGOTIATION_ID)); + assertEquals(3, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_ROUND)); + assertEquals(5, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_MAX_ROUNDS)); + assertEquals("ACCEPT", attrs.get(A2ATAttributes.ATTR_NEGOTIATION_PERFORMATIVE)); + assertEquals(3, attrs.get(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS)); + } + + @Test + void nonTerminalPerformativeOmitsTotalRounds() { + Map attrs = AttributeExtractors.negotiationAttributes( + Map.of("negotiationContext", Map.of("id", "N1", "round", 1, "performative", "PROPOSE"))); + assertFalse(attrs.containsKey(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS)); + assertTrue(AttributeExtractors.isNegotiationMessage(Map.of(NEGOTIATION_URI, "v1"))); + assertFalse(AttributeExtractors.isNegotiationMessage(Map.of("k", "v"))); + } + + @Test + void regexAttributesFallBackToDefaultsAndHonorOptOut() { + Map metadata = Map.of("text", "## 任务类型(Task Type)\n\n配置下发\n## 通知主题\n\nalarm"); + A2ATObservabilityConfig fallback = A2ATObservabilityConfig.fromEnv(); + Map attrs = AttributeExtractors.regexAttributes(metadata, fallback); + assertEquals("配置下发", attrs.get(A2ATAttributes.ATTR_TASK_TYPE)); + assertEquals("alarm", attrs.get(A2ATAttributes.ATTR_NOTIFICATION_TOPIC)); + + Map optedOut = AttributeExtractors.regexAttributes( + metadata, A2ATObservabilityConfig.fromEnv().toBuilder().taskTypeRegex("").build()); + assertFalse(optedOut.containsKey(A2ATAttributes.ATTR_TASK_TYPE)); + } + + @Test + void terminalStateNamesNormalize() { + assertEquals("completed", AttributeExtractors.normalizeTaskStateName("TASK_STATE_COMPLETED")); + assertEquals("canceled", AttributeExtractors.normalizeTaskStateName("TASK_STATE_CANCELED")); + assertNull(AttributeExtractors.normalizeTaskStateName(null)); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("status")); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("artifact")); + assertTrue(A2ATAttributes.EVENT_LOG_KINDS.contains("completed")); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=AttributeExtractorsTest"`。 + +- [ ] **Step 3: 实现** + +`A2ATAttributes.java`(常量值与 spec §5.2 / Python attributes.py 逐字一致): + +```java +package net.openan.a2at.sdk.observability; + +import java.util.Set; + +/** Span attribute keys and shared constants (values byte-identical to the Python SDK). */ +public final class A2ATAttributes { + + public static final String ATTR_EXTENSION_NAME = "gen_ai.agent.a2at.extension.name"; + public static final String ATTR_TASK_ID = "gen_ai.agent.a2at.task.id"; + public static final String ATTR_TASK_STATUS = "gen_ai.agent.a2at.task.status"; + public static final String ATTR_TASK_TYPE = "gen_ai.agent.a2at.task.type"; + public static final String ATTR_NEGOTIATION_ID = "gen_ai.agent.a2at.negotiation.id"; + public static final String ATTR_NEGOTIATION_ROUND = "gen_ai.agent.a2at.negotiation.round"; + public static final String ATTR_NEGOTIATION_MAX_ROUNDS = "gen_ai.agent.a2at.negotiation.max_rounds"; + public static final String ATTR_NEGOTIATION_PERFORMATIVE = "gen_ai.agent.a2at.negotiation.performative"; + public static final String ATTR_NEGOTIATION_TOTAL_ROUNDS = "gen_ai.agent.a2at.negotiation.total_rounds"; + public static final String ATTR_NOTIFICATION_TOPIC = "gen_ai.agent.a2at.notification.topic"; + public static final String ATTR_STREAMING_EVENT_KIND = "gen_ai.agent.a2at.streaming.event.kind"; + public static final String ATTR_PUSH_NOTIFICATION_URL = "gen_ai.agent.a2at.push.notification.url"; + public static final String ATTR_SPAN_SIDE = "a2at.span.side"; + public static final String ATTR_AUTHORIZATION_POLICY_OPERATION_TYPE = + "gen_ai.agent.a2at.authorization.policy.operation.type"; + public static final String ATTR_AUTHORIZATION_POLICY_ID = "authorization.policy.id"; + public static final String ATTR_AUTHORIZATION_OPERATION_TYPE = "authorization.operation_type"; + public static final String ATTR_AUTHORIZATION_OPERATION_RISK_LEVEL = "authorization.operation_risk_level"; + public static final String ATTR_A2A_OPERATION_NAME = "gen_ai.agent.a2a.operation.name"; + public static final String ATTR_A2A_CONTEXT_ID = "gen_ai.agent.a2a.context_id"; + public static final String ATTR_A2A_TASK_ID = "gen_ai.agent.a2a.task_id"; + public static final String ATTR_A2A_MESSAGE_ID = "gen_ai.agent.a2a.message_id"; + public static final String ATTR_A2A_ROLE = "gen_ai.agent.a2a.role"; + public static final String ATTR_A2A_EXTENSIONS = "gen_ai.agent.a2a.extensions"; + public static final String ATTR_A2A_PARTS_NUMBER = "gen_ai.agent.a2a.parts.number"; + public static final String ATTR_A2A_PROTOCOL = "gen_ai.agent.a2a.protocol"; + public static final String ATTR_A2A_REQUEST = "gen_ai.agent.a2a.request"; + public static final String ATTR_A2A_RESPONSE = "gen_ai.agent.a2a.response"; + public static final String ATTR_GEN_AI_OPERATION_NAME = "gen_ai.operation.name"; + public static final String ATTR_GEN_AI_CONVERSATION_ID = "gen_ai.conversation.id"; + public static final String ATTR_GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"; + public static final String ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"; + public static final String ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"; + public static final String ATTR_GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"; + public static final String ATTR_GEN_AI_TOKEN_TYPE = "gen_ai.token.type"; + public static final String ATTR_GEN_AI_PROVIDER_NAME = "gen_ai.provider.name"; + public static final String ATTR_STREAMING = "streaming"; + public static final String ATTR_ERROR_TYPE = "error.type"; + + public static final String NEGOTIATION_EXTENSION = "Negotiation-T"; + public static final String NEGOTIATION_SUFFIX = "-negotiation"; + + public static final Set EVENT_LOG_KINDS = Set.of( + "status", "artifact", "completed", "canceled", "failed", "rejected"); + + private A2ATAttributes() {} +} +``` + +`EventInfo.java`: + +```java +package net.openan.a2at.sdk.observability; + +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** Classified view of one A2A stream event (protocol-agnostic). */ +public record EventInfo( + String kind, + @Nullable String taskId, + @Nullable String taskStatus, + boolean finalEvent, + boolean terminal, + @Nullable Map messageMetadata, + @Nullable String artifactName) { + + public static final EventInfo UNKNOWN = + new EventInfo("unknown", null, null, false, false, null, null); + + public boolean isNegotiationCandidate() { + return "message".equals(kind); + } +} +``` + +`AttributeExtractors.java`: + +```java +package net.openan.a2at.sdk.observability; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + +/** Metadata-level attribute extractors (Map in / attributes out); never raises. */ +public final class AttributeExtractors { + + public static final String EXTENSION_BASE = + "https://projects.tmforum.org/a2aproject/telecommunication/extensions/"; + public static final String DEFAULT_TASK_TYPE_REGEX = "##\\s*任务类型\\(Task Type\\)\\s*\\n+\\s*(\\S[^\\n]*)"; + public static final String DEFAULT_NOTIFICATION_TOPIC_REGEX = "##\\s*通知主题\\s*\\n+\\s*(\\S[^\\n]*)"; + public static final String DEFAULT_AUTHORIZATION_REGEX = "##\\s*授权策略的操作类型\\s*\\n+\\s*(\\S[^\\n]*)"; + private static final java.util.Set TERMINAL_PERFORMATIVES = java.util.Set.of("ACCEPT", "REJECT", "ABORT"); + + private AttributeExtractors() {} + + public static @Nullable String extensionNameFromUri(@Nullable String uri) { + if (uri == null || !uri.startsWith(EXTENSION_BASE)) { + return null; + } + String rest = uri.substring(EXTENSION_BASE.length()); + int slash = rest.indexOf('/'); + String name = slash >= 0 ? rest.substring(0, slash) : rest; + return name.isEmpty() ? null : name; + } + + public static @Nullable String extensionNameFromMetadata(Map metadata) { + for (String key : metadata.keySet()) { + String name = extensionNameFromUri(key); + if (name != null) { + return name; + } + } + return null; + } + + public static boolean isNegotiationMessage(Map metadata) { + return AttributeExtractors.NEGOTIATION_EXTENSION.equals(extensionNameFromMetadata(metadata)); + } + + @SuppressWarnings("unchecked") + public static Map negotiationAttributes(Map metadata) { + Map attrs = new LinkedHashMap<>(); + Object contextObj = metadata.get("negotiationContext"); + if (!(contextObj instanceof Map context)) { + return attrs; + } + Map ctx = (Map) context; + Object id = ctx.get("id"); + if (id instanceof String s && !s.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_ID, s); + } + Integer round = wireInt(ctx.get("round")); + if (round != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_ROUND, round); + } + Integer maxRounds = wireInt(ctx.get("maxRounds")); + if (maxRounds != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_MAX_ROUNDS, maxRounds); + } + Object performative = ctx.get("performative"); + if (performative instanceof String p && !p.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_PERFORMATIVE, p); + if (TERMINAL_PERFORMATIVES.contains(p) && round != null) { + attrs.put(A2ATAttributes.ATTR_NEGOTIATION_TOTAL_ROUNDS, round); + } + } + return attrs; + } + + private static @Nullable Integer wireInt(Object value) { + if (value instanceof Integer i) { + return i; + } + if (value instanceof Number n && n.longValue() == n.doubleValue()) { + return n.intValue(); + } + return null; + } + + public static @Nullable String normalizeTaskStateName(@Nullable String enumName) { + if (enumName == null) { + return null; + } + String prefix = "TASK_STATE_"; + return enumName.startsWith(prefix) ? enumName.substring(prefix.length()).toLowerCase(java.util.Locale.ROOT) + : enumName.toLowerCase(java.util.Locale.ROOT); + } + + public static Map regexAttributes(Map metadata, A2ATObservabilityConfig config) { + Map attrs = new LinkedHashMap<>(); + putRegex(attrs, A2ATAttributes.ATTR_TASK_TYPE, metadata, + config.taskTypeRegex() != null ? config.taskTypeRegex() : DEFAULT_TASK_TYPE_REGEX, false); + putRegex(attrs, A2ATAttributes.ATTR_NOTIFICATION_TOPIC, metadata, + config.notificationTopicRegex() != null ? config.notificationTopicRegex() + : DEFAULT_NOTIFICATION_TOPIC_REGEX, + false); + String authorization = findFirst(metadata, config.authorizationPolicyOperationTypeRegex() != null + ? config.authorizationPolicyOperationTypeRegex() : DEFAULT_AUTHORIZATION_REGEX); + if (authorization != null) { + String cleaned = authorization.replaceAll("[((][^))]*[))]\\s*$", "").trim(); + if (!cleaned.isEmpty()) { + attrs.put(A2ATAttributes.ATTR_AUTHORIZATION_POLICY_OPERATION_TYPE, cleaned); + } + } + return attrs; + } + + private static void putRegex(Map attrs, String key, Map metadata, + @Nullable String pattern, boolean unused) { + if (pattern == null || pattern.isEmpty()) { + return; + } + String found = findFirst(metadata, pattern); + if (found != null && !found.isEmpty()) { + attrs.put(key, found); + } + } + + private static @Nullable String findFirst(Map metadata, @Nullable String pattern) { + if (pattern == null || pattern.isEmpty()) { + return null; + } + Pattern compiled = Pattern.compile(pattern); + for (Object value : metadata.values()) { + if (value instanceof String s) { + Matcher matcher = compiled.matcher(s); + if (matcher.find()) { + return matcher.groupCount() > 0 ? matcher.group(1) : matcher.group(); + } + } + } + return null; + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=AttributeExtractorsTest"`,期望 PASS。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add attribute constants and metadata extractors"` + +--- + +### Task 5: ObservabilityRuntime + +**Files:** +- Create: `.../observability/ObservabilityRuntime.java` +- Test: `.../observability/ObservabilityRuntimeTest.java` + +**Interfaces:** +- Consumes: Task 3 常量键名。 +- Produces: `isEnabled()` / `isTraceEnabled()` / `isMetricEnabled()` / `isLogEnabled()`(每次调用动态读 `System.getProperty` → `System.getenv`,无 import 期冻结——比 Python 可测性更好,行为等价);`tracer()` / `meter()`(`GlobalOpenTelemetry.getTracer/getMeter("a2at-observability", "1.0.0")`,未装 SDK 时 OTel API 天然 no-op);`sameTraceOrphanContext(@Nullable SpanContext) -> @Nullable Context`(同 trace 随机 span_id、无父、0 值守卫)。Task 6-10 与 Plan B 全部消费。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.Scope; +import io.opentelemetry.api.trace.Span; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ObservabilityRuntimeTest { + + @AfterEach + void clearSwitches() { + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + System.clearProperty("A2AT_TRACE_ENABLED"); + } + + @Test + void masterSwitchDisablesAllSignals() { + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + assertFalse(ObservabilityRuntime.isEnabled()); + assertFalse(ObservabilityRuntime.isTraceEnabled()); + assertFalse(ObservabilityRuntime.isMetricEnabled()); + assertFalse(ObservabilityRuntime.isLogEnabled()); + } + + @Test + void traceToggleIsIndependentOfMasterOn() { + System.setProperty("A2AT_TRACE_ENABLED", "false"); + assertTrue(ObservabilityRuntime.isEnabled()); + assertFalse(ObservabilityRuntime.isTraceEnabled()); + assertTrue(ObservabilityRuntime.isMetricEnabled()); + } + + @Test + void sameTraceOrphanContextKeepsTraceIdAndDropsParent() { + SpanContext parent = SpanContext.create( + "0123456789abcdef0123456789abcdef", "0123456789abcdef", + TraceFlags.getSampled(), TraceState.getDefault()); + Context orphan = ObservabilityRuntime.sameTraceOrphanContext(parent); + assertNotNull(orphan); + SpanContext orphanSpan = Span.fromContext(orphan).getSpanContext(); + assertEquals(parent.getTraceId(), orphanSpan.getTraceId()); + assertNotEquals(parent.getSpanId(), orphanSpan.getSpanId()); + assertTrue(orphanSpan.isValid()); + } + + @Test + void invalidParentYieldsNull() { + assertNull(ObservabilityRuntime.sameTraceOrphanContext(SpanContext.getInvalid())); + assertNull(ObservabilityRuntime.sameTraceOrphanContext(null)); + } + + @Test + void tracerAndMeterScopeNames() { + try (Scope scope = Span.fromContext(Context.root()).makeCurrent()) { + assertNotNull(ObservabilityRuntime.tracer()); + assertNotNull(ObservabilityRuntime.meter()); + } + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityRuntimeTest"`。 + +- [ ] **Step 3: 实现** + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.context.Context; +import java.util.concurrent.ThreadLocalRandom; +import org.jspecify.annotations.Nullable; + +/** Master/signal switches + OTel accessors. Switches read dynamically (property > env > default). */ +public final class ObservabilityRuntime { + + public static final String ENV_MASTER = "OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"; + public static final String ENV_TRACE = "A2AT_TRACE_ENABLED"; + public static final String ENV_METRIC = "A2AT_METRIC_ENABLED"; + public static final String ENV_LOG = "A2AT_LOG_ENABLED"; + + public static final String SCOPE_NAME = "a2at-observability"; + public static final String SCOPE_VERSION = "1.0.0"; + + private ObservabilityRuntime() {} + + private static boolean switchOn(String key) { + String raw = System.getProperty(key, System.getenv(key)); + if (raw == null) { + return true; + } + return switch (raw.trim().toLowerCase(java.util.Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + public static boolean isEnabled() { + return switchOn(ENV_MASTER); + } + + public static boolean isTraceEnabled() { + return isEnabled() && switchOn(ENV_TRACE); + } + + public static boolean isMetricEnabled() { + return isEnabled() && switchOn(ENV_METRIC); + } + + public static boolean isLogEnabled() { + return isEnabled() && switchOn(ENV_LOG); + } + + public static Tracer tracer() { + return GlobalOpenTelemetry.getTracer(SCOPE_NAME, SCOPE_VERSION); + } + + public static Meter meter() { + return GlobalOpenTelemetry.getMeter(SCOPE_NAME, SCOPE_VERSION); + } + + /** Same-trace orphan context: new span context shares trace_id, random span_id, no parent. */ + public static @Nullable Context sameTraceOrphanContext(@Nullable SpanContext parent) { + if (parent == null || !parent.isValid()) { + return null; + } + long random = ThreadLocalRandom.current().nextLong(1, Long.MAX_VALUE); + SpanContext orphan = SpanContext.create( + parent.getTraceId(), String.format("%016x", random), + TraceFlags.fromByte(parent.getTraceFlags().asByte()), parent.getTraceState()); + return Context.root().with(io.opentelemetry.api.trace.Span.wrap(orphan)); + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityRuntimeTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add ObservabilityRuntime switches and orphan context"` + +--- + +### Task 6: A2ATSpan + A2ATCurrentSpan + CurrentSpans + +**Files:** +- Create: `.../observability/A2ATSpan.java`、`.../observability/A2ATCurrentSpan.java`、`.../observability/CurrentSpans.java` +- Test: `.../observability/A2ATSpanTest.java` + +**Interfaces:** +- Consumes: Task 5。 +- Produces: `A2ATSpan(String name)` / `A2ATSpan(String, SpanKind)`,链式 `setAttribute(String, Object)` / `addLink(A2ATSpan)`,`open() -> A2ATSpan`(启动并 makeCurrent;trace 关时吸收),`recordError(Throwable)`,`spanContext()`,`close()`(recordError→ERROR+record_exception,否则 OK;try-with-resources);`CurrentSpans.current() -> @Nullable A2ATCurrentSpan`;`A2ATCurrentSpan.traceId()/spanId()/isValid()/setAttribute(String,Object)`。手动 API 面合同(spec §9)。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.testing.exporter.InMemorySpanExporter; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SpanData; +import io.opentelemetry.sdk.trace.export.SimpleSpanProcessor; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class A2ATSpanTest { + + private InMemorySpanExporter exporter; + private SdkTracerProvider provider; + + @BeforeEach + void setUp() { + exporter = InMemorySpanExporter.create(); + provider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(exporter)) + .build(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setTracerProvider(provider).build()); + } + + @AfterEach + void tearDown() { + provider.close(); + GlobalOpenTelemetry.reset(); + } + + @Test + void tryWithResourcesEndsWithOk() { + try (A2ATSpan span = new A2ATSpan("manual.op", SpanKind.INTERNAL).setAttribute("k", "v").open()) { + span.setAttribute("k2", 2); + assertNotNull(CurrentSpans.current()); + assertNotNull(CurrentSpans.current().traceId()); + } + List ended = exporter.getFinishedSpanItems(); + assertEquals(1, ended.size()); + assertEquals("manual.op", ended.get(0).getName()); + assertEquals(io.opentelemetry.api.trace.StatusCode.OK, ended.get(0).getStatus().getStatusCode()); + assertEquals("v", ended.get(0).getAttributes().get( + io.opentelemetry.api.common.AttributeKey.stringKey("k"))); + } + + @Test + void recordErrorEndsWithErrorAndException() { + RuntimeException failure = new IllegalStateException("boom"); + try (A2ATSpan span = new A2ATSpan("manual.fail").open()) { + span.recordError(failure); + } + SpanData data = exporter.getFinishedSpanItems().get(0); + assertEquals(io.opentelemetry.api.trace.StatusCode.ERROR, data.getStatus().getStatusCode()); + assertFalse(data.getEvents().isEmpty()); + assertTrue(data.getStatus().getDescription().contains("boom")); + } + + @Test + void addLinkBeforeOpenCreatesLink() { + try (A2ATSpan entry = new A2ATSpan("entry").open()) { + try (A2ATSpan event = new A2ATSpan("event").addLink(entry).open()) { + // linked at creation + } + } + List ended = exporter.getFinishedSpanItems(); + SpanData event = ended.stream().filter(d -> d.getName().equals("event")).findFirst().orElseThrow(); + assertEquals(1, event.getLinks().size()); + assertEquals( + ended.stream().filter(d -> d.getName().equals("entry")).findFirst().orElseThrow() + .getSpanContext(), + event.getLinks().get(0).getSpanContext()); + } + + @Test + void singleUseAndClosedAbsorb() { + A2ATSpan span = new A2ATSpan("once").open(); + assertThrows(IllegalStateException.class, span::open); + span.close(); + span.close(); + } + + @Test + void currentSpanNullOutsideAndValidInside() { + assertNull(CurrentSpans.current()); + try (A2ATSpan span = new A2ATSpan("s").open()) { + A2ATCurrentSpan current = CurrentSpans.current(); + assertNotNull(current); + assertTrue(current.isValid()); + assertEquals(32, current.traceId().length()); + assertEquals(16, current.spanId().length()); + current.setAttribute("cur", "x"); + } + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATSpanTest"`。 + +- [ ] **Step 3: 实现** + +`A2ATSpan.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanBuilder; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.api.trace.StatusCode; +import io.opentelemetry.context.Scope; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Manual A2A-T span; try-with-resources + open(). Absorbs everything when trace is disabled. */ +public final class A2ATSpan implements AutoCloseable { + + private final String name; + private final SpanKind kind; + private final Map attributes = new LinkedHashMap<>(); + private final List pendingLinks = new ArrayList<>(); + private Span span; + private Scope scope; + private boolean opened; + private boolean closed; + private boolean errorRecorded; + + public A2ATSpan(String name) { + this(name, SpanKind.INTERNAL); + } + + public A2ATSpan(String name, SpanKind kind) { + this.name = name; + this.kind = kind; + } + + public A2ATSpan setAttribute(String key, Object value) { + if (span != null) { + applyAttribute(span, key, value); + } else { + attributes.put(key, value); + } + return this; + } + + public A2ATSpan addLink(A2ATSpan other) { + SpanContext context = other == null ? null : other.spanContext(); + if (context == null || !context.isValid()) { + return this; + } + if (span != null) { + span.addLink(context); + } else { + pendingLinks.add(context); + } + return this; + } + + /** Starts the span and makes it current; idempotent-guarded, absorbs when trace is off. */ + public A2ATSpan open() { + if (opened) { + throw new IllegalStateException("A2ATSpan is single-use; create a new instance for each span"); + } + opened = true; + if (!ObservabilityRuntime.isTraceEnabled()) { + return this; + } + SpanBuilder builder = ObservabilityRuntime.tracer().spanBuilder(name).setSpanKind(kind); + for (SpanContext link : pendingLinks) { + builder.addLink(link); + } + span = builder.startSpan(); + attributes.forEach(this::applyOnSpan); + scope = span.makeCurrent(); + return this; + } + + private void applyOnSpan(String key, Object value) { + if (span != null) { + applyAttribute(span, key, value); + } + } + + private static void applyAttribute(Span target, String key, Object value) { + switch (value) { + case String s -> target.setAttribute(key, s); + case Integer i -> target.setAttribute(key, i); + case Long l -> target.setAttribute(key, l); + case Boolean b -> target.setAttribute(key, b); + case Double d -> target.setAttribute(key, d); + case null, default -> target.setAttribute(key, String.valueOf(value)); + } + } + + public void recordError(Throwable error) { + if (span == null || error == null) { + return; + } + errorRecorded = true; + span.recordException(error); + span.setStatus(StatusCode.ERROR, String.valueOf(error.getMessage())); + } + + public SpanContext spanContext() { + return span != null ? span.getSpanContext() : SpanContext.getInvalid(); + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + try (Scope restore = scope) { + if (span != null && span.isRecording()) { + if (!errorRecorded) { + span.setStatus(StatusCode.OK); + } + span.end(); + } + } + } +} +``` + +注意:`errorRecorded` 守卫保证 `recordError` 后 `close` 不再覆盖为 OK(Python `__exit__(exc)` 语义的 Java 等价)。 + +`A2ATCurrentSpan.java` + `CurrentSpans.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; + +/** Read/append view of the active span. */ +public final class A2ATCurrentSpan { + private final Span span; + + A2ATCurrentSpan(Span span) { + this.span = span; + } + + public String traceId() { return span.getSpanContext().getTraceId(); } + public String spanId() { return span.getSpanContext().getSpanId(); } + public boolean isValid() { return span.getSpanContext().isValid(); } + public void setAttribute(String key, Object value) { span.setAttribute(key, String.valueOf(value)); } +} +``` + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.trace.Span; +import org.jspecify.annotations.Nullable; + +/** Accessor for the active A2A-T span. */ +public final class CurrentSpans { + + private CurrentSpans() {} + + public static @Nullable A2ATCurrentSpan current() { + Span span = Span.current(); + if (!span.getSpanContext().isValid() || !span.isRecording()) { + return null; + } + return new A2ATCurrentSpan(span); + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=A2ATSpanTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add manual span API"` + +--- + +### Task 7: TracePropagation + PayloadExtractor + PayloadSanitizer + +**Files:** +- Create: `.../observability/TracePropagation.java`、`.../observability/PayloadExtractor.java`、`.../observability/PayloadSanitizer.java` +- Test: `.../observability/PropagationAndPayloadTest.java` + +**Interfaces:** +- Produces: `TracePropagation.TRACEPARENT_HEADER="traceparent"`;`inject(Map)`(当前上下文)/ `inject(Map, Context)`;`extract(Map) -> @Nullable Context`(key 小写容错;无效返回 null)。`PayloadExtractor.toJson(@Nullable Object) -> @Nullable String`(Jackson;失败 DEBUG+null)。`PayloadSanitizer.prepare(String, config)`(先脱敏后截断,`[truncated]` 后缀)。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.context.Context; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class PropagationAndPayloadTest { + + @AfterEach + void reset() { + io.opentelemetry.api.GlobalOpenTelemetry.reset(); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void injectExtractRoundtripSurvivesLowercasing() { + io.opentelemetry.api.GlobalOpenTelemetry.set(io.opentelemetry.sdk.OpenTelemetrySdk.builder() + .setTracerProvider(io.opentelemetry.sdk.trace.SdkTracerProvider.builder().build()).build()); + Map headers = new HashMap<>(); + String traceId; + try (var span = new A2ATSpan("root", io.opentelemetry.api.trace.SpanKind.CLIENT).open()) { + traceId = Span.current().getSpanContext().getTraceId(); + TracePropagation.inject(headers); + } + Map mixed = Map.of("TRACEPARENT", headers.get("traceparent")); + Context extracted = TracePropagation.extract(mixed); + org.junit.jupiter.api.Assertions.assertNotNull(extracted); + assertEquals(traceId, Span.fromContext(extracted).getSpanContext().getTraceId()); + assertTrue(headers.get("traceparent").startsWith("00-" + traceId)); + } + + @Test + void extractReturnsNullWhenInvalidOrDisabled() { + assertNull(TracePropagation.extract(Map.of("traceparent", "00-abc-def-01"))); + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + assertNull(TracePropagation.extract(Map.of("traceparent", "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"))); + } + + @Test + void payloadRedactsBeforeTruncation() { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv().toBuilder() + .payloadLogMaxLength(10) + .payloadRedactor(value -> value.replace("secret", "***")) + .build(); + String prepared = PayloadSanitizer.prepare("secret-abcdefghijklmnopqrstuvwxyz", config); + assertFalse(prepared.contains("secret")); + assertTrue(prepared.endsWith("[truncated]")); + assertTrue(prepared.length() <= 10 + "[truncated]".length()); + } + + @Test + void payloadExtractorSerializesRecordsAndNullFails() { + record Point(int x, int y) {} + assertTrue(PayloadExtractor.toJson(new Point(1, 2)).contains("\"x\":1")); + assertNull(PayloadExtractor.toJson(null)); + assertNull(PayloadExtractor.toJson(new Object() { + @Override + public String toString() { + throw new IllegalStateException("unserializable"); + } + })); + } +} +``` + +注意:Jackson 序列化匿名类默认会抛 `InvalidDefinitionException` → `toJson` 捕获返回 null,正是断言路径。 + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=PropagationAndPayloadTest"`。 + +- [ ] **Step 3: 实现** + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.context.Context; +import io.opentelemetry.context.propagation.TextMapGetter; +import io.opentelemetry.context.propagation.TextMapSetter; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import org.jspecify.annotations.Nullable; + +/** W3C TraceContext propagation over plain header maps. */ +public final class TracePropagation { + + public static final String TRACEPARENT_HEADER = "traceparent"; + + private static final TextMapSetter> SETTER = Map::put; + private static final TextMapGetter> GETTER = new TextMapGetter<>() { + @Override + public Iterable keys(Map carrier) { + return carrier.keySet(); + } + + @Override + public @Nullable String get(@Nullable Map carrier, String key) { + return carrier == null ? null : carrier.get(key); + } + }; + + private TracePropagation() {} + + public static void inject(Map headers) { + if (headers == null || !ObservabilityRuntime.isEnabled()) { + return; + } + GlobalOpenTelemetry.getPropagators().getTextMapPropagator() + .inject(Context.current(), headers, SETTER); + } + + public static void inject(Map headers, Context context) { + if (headers == null || context == null || !ObservabilityRuntime.isEnabled()) { + return; + } + GlobalOpenTelemetry.getPropagators().getTextMapPropagator().inject(context, headers, SETTER); + } + + public static @Nullable Context extract(@Nullable Map headers) { + if (headers == null || !ObservabilityRuntime.isEnabled()) { + return null; + } + Map lowered = new HashMap<>(); + headers.forEach((key, value) -> lowered.put(key.toLowerCase(Locale.ROOT), value)); + Context extracted = GlobalOpenTelemetry.getPropagators().getTextMapPropagator() + .extract(Context.root(), lowered, GETTER); + SpanContextProbe probe = new SpanContextProbe(extracted); + return probe.isValid() ? extracted : null; + } + + private static final class SpanContextProbe { + private final boolean valid; + + SpanContextProbe(Context context) { + this.valid = io.opentelemetry.api.trace.Span.fromContext(context).getSpanContext().isValid(); + } + + boolean isValid() { + return valid; + } + } +} +``` + +(实现可内联为单行 `Span.fromContext(extracted).getSpanContext().isValid() ? extracted : null`,无需内部类——以简洁版为准。) + +```java +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Serializes request/event objects to JSON for payload channels. */ +public final class PayloadExtractor { + + private static final Logger LOG = LoggerFactory.getLogger(PayloadExtractor.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private PayloadExtractor() {} + + public static @Nullable String toJson(@Nullable Object value) { + if (value == null) { + return null; + } + try { + return MAPPER.writeValueAsString(value); + } catch (Exception e) { + LOG.debug("a2at: payload extraction failed", e); + return null; + } + } +} +``` + +```java +package net.openan.a2at.sdk.observability; + +import java.util.function.UnaryOperator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Shared payload preparation: redact first, then truncate (security order). */ +public final class PayloadSanitizer { + + private static final Logger LOG = LoggerFactory.getLogger(PayloadSanitizer.class); + + private PayloadSanitizer() {} + + public static String prepare(String payload, A2ATObservabilityConfig config) { + String value = payload; + UnaryOperator redactor = config.payloadRedactor(); + if (redactor != null) { + try { + value = redactor.apply(value); + } catch (RuntimeException e) { + LOG.warn("a2at: payload redactor failed; using unredacted value", e); + } + } + int max = config.payloadLogMaxLength(); + if (value.length() > max) { + return value.substring(0, max) + "[truncated]"; + } + return value; + } +} +``` + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=PropagationAndPayloadTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add propagation and payload channels"` + +--- + +### Task 8: LlmUsageStash + ObservabilityLLMClient + MetricHistograms + NegotiationMetrics + +**Files:** +- Create: `.../observability/LlmUsageStash.java`、`.../observability/LlmUsageStashHolder.java`、`.../observability/MetricHistograms.java`、`.../observability/NegotiationMetrics.java`、`.../observability/ObservabilityLLMClient.java` +- Test: `.../observability/LlmObservabilityTest.java`、`.../observability/NegotiationMetricsTest.java` + +**Interfaces:** +- Consumes: `LLMClient`/`LLMResponse`(a2a-t-llm;`usage` 键 `prompt_tokens`/`completion_tokens`)、Task 4 常量、Task 5 开关。 +- Produces: + - `record LlmUsageStash(@Nullable Integer inputTokens, @Nullable Integer outputTokens, @Nullable String requestModel)` + - `LlmUsageStashHolder.set(stash)` / `getAndClear()` / `clear()`(ThreadLocal) + - `ObservabilityLLMClient implements LLMClient`(包装 delegate;调用后 stash + 记 `gen_ai.client.token.usage` 直方图,`gen_ai.token.type`=input/output;metric 关时仅 stash) + - `MetricHistograms.histogram(name, unit, description) -> LongHistogram`(ConcurrentHashMap 惰性缓存;package-private) + - `NegotiationMetrics.reportRounds(@Nullable String negotiationId, @Nullable String outcome, int rounds)`(outcome ∈ {accept,reject,abort};`extension.name`+`negotiation.id`+`outcome` 属性;metric 关 → 跳过;永不抛) + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.PeriodicExportingMetricReader; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LlmObservabilityTest { + + private InMemoryMetricReader reader; + + @BeforeEach + void setUp() { + reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder() + .registerMetricReader(reader) + .build(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().setMeterProvider(provider).build()); + } + + @AfterEach + void tearDown() { + GlobalOpenTelemetry.reset(); + } + + @Test + void structuredStashesUsageAndRecordsTokenHistogram() { + LLMClient scripted = (messages, schema, temperature, maxTokens) -> new LLMResponse( + "ok", "gpt-x", Map.of("prompt_tokens", 10, "completion_tokens", 5), Map.of()); + ObservabilityLLMClient client = new ObservabilityLLMClient(scripted); + client.structured(List.of(Map.of("role", "user", "content", "hi")), Map.of(), null, null); + + LlmUsageStash stash = LlmUsageStashHolder.getAndClear(); + assertNotNull(stash); + assertEquals(10, stash.inputTokens()); + assertEquals(5, stash.outputTokens()); + assertEquals("gpt-x", stash.requestModel()); + assertNull(LlmUsageStashHolder.getAndClear()); + + var metrics = reader.collectAllMetrics().join(); + assertTrue(metrics.stream().anyMatch(m -> "gen_ai.client.token.usage".equals(m.getName()) + && !m.getHistogramData().getDataPoints().isEmpty()), + "token usage histogram must be recorded"); + } + + @Test + void metricDisabledStillStashes() { + System.setProperty("A2AT_METRIC_ENABLED", "false"); + try { + LLMClient scripted = (messages, schema, temperature, maxTokens) -> + new LLMResponse("ok", "m", Map.of("prompt_tokens", 1, "completion_tokens", 2), Map.of()); + new ObservabilityLLMClient(scripted).structured(List.of(), Map.of(), null, null); + assertNotNull(LlmUsageStashHolder.getAndClear()); + assertEquals(0, reader.collectAllMetrics().join().size()); + } finally { + System.clearProperty("A2AT_METRIC_ENABLED"); + } + } + + @Test + void stashFailurePathKeepsSemantics() { + // delegate 异常原样抛出,不写 stash(观测永不破坏业务) + LLMClient throwing = (messages, schema, temperature, maxTokens) -> { + throw new IllegalStateException("llm down"); + }; + org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, + () -> new ObservabilityLLMClient(throwing).structured(List.of(), Map.of(), null, null)); + assertNull(LlmUsageStashHolder.getAndClear()); + } +} +``` + +注意:`LLMResponse.usage` 类型为 `Map`(键 `prompt_tokens`/`completion_tokens`,见 `a2a-t-llm` 的 `OpenAIClient`),类型约束由编译器保证,无需非法值用例。 + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NegotiationMetricsTest { + + private InMemoryMetricReader reader; + + @BeforeEach + void setUp() { + reader = InMemoryMetricReader.create(); + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder() + .setMeterProvider(SdkMeterProvider.builder().registerMetricReader(reader).build()).build()); + } + + @AfterEach + void tearDown() { + GlobalOpenTelemetry.reset(); + } + + @Test + void terminalOutcomeRecordsRoundsWithAttributes() { + NegotiationMetrics.reportRounds("N001", "accept", 3); + var metrics = reader.collectAllMetrics().join(); + var histogram = metrics.stream() + .filter(m -> "a2at.negotiation.total_rounds".equals(m.getName())) + .findFirst().orElseThrow(); + assertEquals("a2at.negotiation.total_rounds", histogram.getName()); + assertTrue(!histogram.getHistogramData().getDataPoints().isEmpty()); + } + + @Test + void nonTerminalOutcomeAndNegativeRoundsAreSkipped() { + NegotiationMetrics.reportRounds("N1", "propose", 2); + NegotiationMetrics.reportRounds("N1", "accept", -1); + assertEquals(0, reader.collectAllMetrics().join().size()); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=LlmObservabilityTest,NegotiationMetricsTest"`。 + +> 依赖补充:`InMemoryMetricReader` 来自 `io.opentelemetry:opentelemetry-sdk-testing:1.64.0` —— 在 `a2a-t-observability/pom.xml` 增加测试依赖(版本经 bom 管理): +> ```xml +> +> io.opentelemetry +> opentelemetry-sdk-testing +> test +> +> ``` + +- [ ] **Step 3: 实现** + +`LlmUsageStash.java` + `LlmUsageStashHolder.java`: + +```java +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.Nullable; + +/** Token usage captured from one LLM call; drained onto the next entry span. */ +public record LlmUsageStash( + @Nullable Integer inputTokens, + @Nullable Integer outputTokens, + @Nullable String requestModel) {} +``` + +```java +package net.openan.a2at.sdk.observability; + +import org.jspecify.annotations.Nullable; + +/** ThreadLocal bridge: LLM layer -> entry span (SDK is fully synchronous). */ +public final class LlmUsageStashHolder { + + private static final ThreadLocal STASH = new ThreadLocal<>(); + + private LlmUsageStashHolder() {} + + public static void set(LlmUsageStash usage) { + STASH.set(usage); + } + + public static @Nullable LlmUsageStash getAndClear() { + LlmUsageStash stash = STASH.get(); + STASH.remove(); + return stash; + } + + public static void clear() { + STASH.remove(); + } +} +``` + +`MetricHistograms.java`(package-private): + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.metrics.LongHistogram; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** Lazy histogram cache keyed by metric name (shared meter scope a2at-observability/1.0.0). */ +final class MetricHistograms { + + private static final ConcurrentMap INSTRUMENTS = new ConcurrentHashMap<>(); + + private MetricHistograms() {} + + static LongHistogram histogram(String name, String unit, String description) { + return INSTRUMENTS.computeIfAbsent(name, metricName -> ObservabilityRuntime.meter() + .histogramBuilder(metricName) + .setUnit(unit) + .setDescription(description) + .ofLongs() + .build()); + } +} +``` + +`NegotiationMetrics.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.common.Attributes; +import java.util.Set; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** a2at.negotiation.total_rounds histogram; terminal outcomes only; never raises. */ +public final class NegotiationMetrics { + + public static final String METRIC_NAME = "a2at.negotiation.total_rounds"; + private static final Logger LOG = LoggerFactory.getLogger(NegotiationMetrics.class); + private static final Set OUTCOMES = Set.of("accept", "reject", "abort"); + + private NegotiationMetrics() {} + + public static void reportRounds(@Nullable String negotiationId, @Nullable String outcome, int rounds) { + try { + if (!ObservabilityRuntime.isMetricEnabled() || rounds < 0) { + return; + } + if (outcome == null || !OUTCOMES.contains(outcome.toLowerCase(java.util.Locale.ROOT))) { + return; + } + var builder = Attributes.builder(); + builder.put(A2ATAttributes.ATTR_EXTENSION_NAME, A2ATAttributes.NEGOTIATION_EXTENSION); + if (negotiationId != null && !negotiationId.isEmpty()) { + builder.put(A2ATAttributes.ATTR_NEGOTIATION_ID, negotiationId); + } + builder.put("outcome", outcome.toLowerCase(java.util.Locale.ROOT)); + MetricHistograms.histogram(METRIC_NAME, "{round}", "Negotiation terminal message round count") + .record(rounds, builder.build()); + } catch (RuntimeException e) { + LOG.warn("a2at: failed to report negotiation rounds", e); + } + } +} +``` + +`ObservabilityLLMClient.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.common.Attributes; +import java.util.List; +import java.util.Map; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMResponse; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** LLMClient decorator: usage stash + gen_ai.client.token.usage histogram; never breaks the call. */ +public final class ObservabilityLLMClient implements LLMClient { + + public static final String METRIC_TOKEN_USAGE = "gen_ai.client.token.usage"; + private static final Logger LOG = LoggerFactory.getLogger(ObservabilityLLMClient.class); + private final LLMClient delegate; + + public ObservabilityLLMClient(LLMClient delegate) { + this.delegate = java.util.Objects.requireNonNull(delegate); + } + + @Override + public LLMResponse structured( + List> messages, Map jsonSchema, + @Nullable Double temperature, @Nullable Integer maxTokens) { + LLMResponse response = delegate.structured(messages, jsonSchema, temperature, maxTokens); + report(response); + return response; + } + + private void report(LLMResponse response) { + try { + Map usage = response.usage() == null ? Map.of() : response.usage(); + Integer input = usage.get("prompt_tokens"); + Integer output = usage.get("completion_tokens"); + LlmUsageStashHolder.set(new LlmUsageStash(input, output, response.model())); + if (ObservabilityRuntime.isMetricEnabled()) { + recordTokenUsage(input, output); + } + } catch (RuntimeException e) { + LOG.warn("a2at: failed to report llm token usage", e); + } + } + + private void recordTokenUsage(@Nullable Integer input, @Nullable Integer output) { + if (input != null) { + MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") + .record(input, Attributes.of(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE, "input")); + } + if (output != null) { + MetricHistograms.histogram(METRIC_TOKEN_USAGE, "{token}", "GenAI client token usage") + .record(output, Attributes.of(A2ATAttributes.ATTR_GEN_AI_TOKEN_TYPE, "output")); + } + } +} +``` + +(`LongHistogram unused` 残行已清理——以上码即最终版。) + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=LlmObservabilityTest,NegotiationMetricsTest"`。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add LLM usage stash, token metric and negotiation rounds metric"` + +--- + +### Task 9: ObservabilityLogs + ConsoleExporters + ObservabilitySetup + +**Files:** +- Create: `.../observability/ObservabilityLogs.java`、`.../observability/ConsoleExporters.java`、`.../observability/ObservabilitySetup.java` +- Test: `.../observability/ObservabilityLogsTest.java`、`.../observability/ObservabilitySetupTest.java` + +**Interfaces:** +- Consumes: Task 3 config、Task 5 开关、Task 7 sanitizer。 +- Produces: + - `ObservabilityLogs.logEvent(String event, org.slf4j.event.Level level, Map fields, @Nullable String payload, A2ATObservabilityConfig config)`:SLF4J JSON 行(logger `net.openan.a2at.sdk.observability`,字段 event + fields + trace_id/span_id + payload?)+ OTel `Logger` API 同步发射(`GlobalLoggerProvider.get().get("a2at-observability")`);log_enabled 双门控;永不抛。 + - `ConsoleExporters`(package-private):`spanExporter()` / `metricExporter()` / `logRecordExporter()` —— 内置 SLF4J JSON 导出器(`opentelemetry-exporter-logging` 不在 .m2,等效自研)。 + - `ObservabilitySetup.ensureConfigured()` / `ensureConfigured(A2ATObservabilityConfig)` / `isConfigured()`:懒装配三信号(SdkTracerProvider/SdkMeterProvider/SdkLoggerProvider + Batch* + PeriodicExportingMetricReader);endpoint → OTLP gRPC/HTTP(HTTP 拼 `/v1/traces|metrics|logs`);无 endpoint → Console(`A2AT_CONSOLE_EXPORTER=false` 且无 endpoint → 信号保持 no-op);幂等(once 标志 + `GlobalOpenTelemetry.set` 抛 `IllegalStateException` 捕获跳过);sdk 类缺失 `Class.forName` 探测跳过。 + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.GlobalOpenTelemetry; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +class ObservabilityLogsTest { + + private ListAppender appender; + private Logger logger; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void attach() { + logger = (Logger) LoggerFactory.getLogger("net.openan.a2at.sdk.observability"); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(ch.qos.logback.classic.Level.DEBUG); + GlobalOpenTelemetry.reset(); + } + + @AfterEach + void detach() { + logger.detachAppender(appender); + System.clearProperty("A2AT_LOG_ENABLED"); + System.clearProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED"); + } + + @Test + void jsonRecordCarriesEventFieldsAndTraceIds() throws Exception { + try (A2ATSpan span = new A2ATSpan("entry").open()) { + ObservabilityLogs.logEvent("task.status_changed", Level.INFO, + Map.of("task.id", "t1", "task.status", "working"), null, A2ATObservabilityConfig.fromEnv()); + } + ILoggingEvent event = appender.list.stream() + .filter(e -> e.getLevel() == Level.INFO).findFirst().orElseThrow(); + Map json = mapper.readValue(event.getFormattedMessage(), Map.class); + assertEquals("task.status_changed", json.get("event")); + assertEquals("t1", json.get("task.id")); + assertEquals("working", json.get("task.status")); + assertNotNull(json.get("trace_id")); + assertNotNull(json.get("span_id")); + } + + @Test + void payloadChannelHonorsConfigAndRedaction() throws Exception { + A2ATObservabilityConfig config = A2ATObservabilityConfig.fromEnv().toBuilder() + .payloadLogEnabled(true) + .payloadRedactor(v -> v.replace("key", "***")) + .build(); + ObservabilityLogs.logEvent("task.request", Level.DEBUG, Map.of(), "payload: key=1", config); + Map json = mapper.readValue(appender.list.get(0).getFormattedMessage(), Map.class); + assertTrue(String.valueOf(json.get("payload")).contains("***")); + assertFalse(String.valueOf(json.get("payload")).contains("key=1")); + } + + @Test + void logSwitchOffEmitsNothing() { + System.setProperty("A2AT_LOG_ENABLED", "false"); + ObservabilityLogs.logEvent("task.request", Level.DEBUG, Map.of(), null, A2ATObservabilityConfig.fromEnv()); + System.clearProperty("A2AT_LOG_ENABLED"); + assertEquals(0, appender.list.size()); + } +} +``` + +```java +package net.openan.a2at.sdk.observability; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class ObservabilitySetupTest { + + @AfterEach + void reset() { + GlobalOpenTelemetry.reset(); + System.clearProperty("A2AT_OBSERVABILITY_ENABLED"); + } + + @Test + void consoleFallbackConfiguresAllThreeSignals() { + GlobalOpenTelemetry.reset(); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertTrue(ObservabilitySetup.isConfigured()); + assertInstanceOf(OpenTelemetrySdk.class, GlobalOpenTelemetry.get()); + GlobalOpenTelemetry.getTracer("t").spanBuilder("s").startSpan().end(); + } + + @Test + void existingGlobalProviderIsRespected() { + GlobalOpenTelemetry.set(OpenTelemetrySdk.builder().build()); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertFalse(ObservabilitySetup.isConfigured()); + } + + @Test + void disabledSkipsEverything() { + System.setProperty("OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED", "false"); + ObservabilitySetup.ensureConfigured(A2ATObservabilityConfig.fromEnv()); + assertFalse(ObservabilitySetup.isConfigured()); + assertFalse(ObservabilityRuntime.isEnabled()); + } +} +``` + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityLogsTest,ObservabilitySetupTest"`。 + +- [ ] **Step 3: 实现** + +`ObservabilityLogs.java`: + +```java +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.logs.Logger; +import io.opentelemetry.api.logs.SeverityNumber; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.slf4j.event.Level; + +/** Structured JSON events: SLF4J channel + OTel logs channel sharing identical semantics. */ +public final class ObservabilityLogs { + + public static final String LOGGER_NAME = "net.openan.a2at.sdk.observability"; + private static final org.slf4j.Logger LOG = org.slf4j.LoggerFactory.getLogger(LOGGER_NAME); + private static final Logger OTEL_LOGGER = io.opentelemetry.api.logs.GlobalLoggerProvider.get() + .get(ObservabilityRuntime.SCOPE_NAME); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ObservabilityLogs() {} + + public static void logEvent(String event, Level level, Map fields, + @Nullable String payload, A2ATObservabilityConfig config) { + if (!ObservabilityRuntime.isLogEnabled() || !config.logEnabled()) { + return; + } + try { + Map data = new LinkedHashMap<>(); + data.put("event", event); + if (fields != null) { + data.putAll(fields); + } + var spanContext = io.opentelemetry.api.trace.Span.current().getSpanContext(); + if (spanContext.isValid()) { + data.put("trace_id", spanContext.getTraceId()); + data.put("span_id", spanContext.getSpanId()); + } + if (payload != null && config.payloadLogEnabled()) { + data.put("payload", PayloadSanitizer.prepare(payload, config)); + } + String json = MAPPER.writeValueAsString(data); + LOG.atLevel(level).log(json); + emitOtel(level, data, json); + } catch (Exception e) { + LOG.debug("a2at: log_event failed", e); + } + } + + private static void emitOtel(Level level, Map data, String json) { + try { + var builder = OTEL_LOGGER.logRecordBuilder() + .setSeverity(severity(level)) + .setSeverityText(level.name()) + .setBody(json); + data.forEach((key, value) -> { + if (!"event".equals(key) && !"trace_id".equals(key) && !"span_id".equals(key)) { + builder.setAttribute(key, String.valueOf(value)); + } + }); + builder.emit(); + } catch (RuntimeException e) { + LOG.debug("a2at: otel log emit failed", e); + } + } + + private static SeverityNumber severity(Level level) { + return switch (level) { + case DEBUG -> SeverityNumber.DEBUG; + case INFO -> SeverityNumber.INFO; + case WARN -> SeverityNumber.WARN; + case ERROR -> SeverityNumber.ERROR; + default -> SeverityNumber.INFO; + }; + } +} +``` + +`ConsoleExporters.java`(package-private,先 `javap -cp io.opentelemetry.sdk.metrics.export.MetricExporter io.opentelemetry.sdk.logs.export.LogRecordExporter io.opentelemetry.sdk.trace.export.SpanExporter` 核对 1.64 方法签名,若与下码不符按实际调整): + +```java +package net.openan.a2at.sdk.observability; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.logs.data.LogRecordData; +import io.opentelemetry.sdk.logs.export.LogRecordExporter; +import io.opentelemetry.sdk.metrics.InstrumentType; +import io.opentelemetry.sdk.metrics.data.AggregationTemporality; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.trace.data.SpanData; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import java.util.Collection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** In-house dev-mode exporters (JSON lines on SLF4J); replaces opentelemetry-exporter-logging. */ +final class ConsoleExporters { + + private static final Logger LOG = LoggerFactory.getLogger("net.openan.a2at.sdk.observability.console"); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ConsoleExporters() {} + + static SpanExporter spanExporter() { + return new SpanExporter() { + @Override + public CompletableResultCode export(Collection spans) { + spans.forEach(span -> LOG.info(json("span", span.getName(), + java.util.Map.of( + "traceId", span.getTraceId(), "spanId", span.getSpanId(), + "kind", span.getKind().name(), + "status", span.getStatus().getStatusCode().name(), + "attributes", span.getAttributes().asMap())))); + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.success(); + } + }; + } + + static MetricExporter metricExporter() { + return new MetricExporter() { + @Override + public CompletableResultCode export(Collection metrics) { + metrics.forEach(metric -> LOG.info(json("metric", metric.getName(), + java.util.Map.of("unit", String.valueOf(metric.getUnit()), + "description", String.valueOf(metric.getDescription()))))); + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.success(); + } + + @Override + public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) { + return AggregationTemporality.CUMULATIVE; + } + }; + } + + static LogRecordExporter logRecordExporter() { + return new LogRecordExporter() { + @Override + public CompletableResultCode export(Collection records) { + records.forEach(record -> LOG.info(json("log", String.valueOf(record.getBodyValue()), + java.util.Map.of( + "severity", String.valueOf(record.getSeverityText()), + "attributes", record.getAttributes().asMap())))); + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode flush() { + return CompletableResultCode.success(); + } + + @Override + public CompletableResultCode shutdown() { + return CompletableResultCode.success(); + } + }; + } + + private static String json(String signal, String name, Object data) { + try { + return MAPPER.writeValueAsString(java.util.Map.of("signal", signal, "name", name, "data", data)); + } catch (Exception e) { + return "{\"signal\":\"" + signal + "\",\"error\":\"serialize_failed\"}"; + } + } +} +``` + +`ObservabilitySetup.java`: + +```java +package net.openan.a2at.sdk.observability; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter; +import io.opentelemetry.exporter.otlp.http.metrics.OtlpHttpMetricExporter; +import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter; +import io.opentelemetry.exporter.otlp.logs.OtlpGrpcLogRecordExporter; +import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.logs.SdkLoggerProvider; +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor; +import io.opentelemetry.sdk.metrics.PeriodicExportingMetricReader; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Lazy auto-config of all three signals; user-registered providers are never overridden. */ +public final class ObservabilitySetup { + + private static final Logger LOG = LoggerFactory.getLogger(ObservabilitySetup.class); + private static final AtomicBoolean CONFIGURED = new AtomicBoolean(false); + + private ObservabilitySetup() {} + + public static boolean isConfigured() { + return CONFIGURED.get(); + } + + public static void ensureConfigured() { + ensureConfigured(A2ATObservabilityConfig.fromEnv()); + } + + public static void ensureConfigured(A2ATObservabilityConfig config) { + if (CONFIGURED.get() || !ObservabilityRuntime.isEnabled() || !config.enabled()) { + return; + } + if (!sdkAvailable()) { + return; + } + try { + configure(config); + CONFIGURED.set(true); + } catch (IllegalStateException alreadySet) { + LOG.debug("a2at: global OTel already registered; skipping auto-config"); + } catch (RuntimeException e) { + LOG.warn("a2at: otel auto-config failed", e); + } + } + + private static boolean sdkAvailable() { + try { + Class.forName("io.opentelemetry.sdk.OpenTelemetrySdk"); + return true; + } catch (ClassNotFoundException e) { + LOG.warn("a2at: opentelemetry-sdk not on classpath; auto-config skipped"); + return false; + } + } + + private static void configure(A2ATObservabilityConfig config) { + Resource resource = buildResource(config); + Exporters exporters = buildExporters(config); + SdkTracerProvider tracerProvider = null; + SdkMeterProvider meterProvider = null; + SdkLoggerProvider loggerProvider = null; + if (exporters.span != null && config.traceEnabled() && ObservabilityRuntime.isTraceEnabled()) { + tracerProvider = SdkTracerProvider.builder() + .setResource(resource) + .addSpanProcessor(BatchSpanProcessor.builder(exporters.span) + .setExporterTimeout(2, TimeUnit.SECONDS).build()) + .build(); + } + if (exporters.metric != null && config.metricEnabled() && ObservabilityRuntime.isMetricEnabled()) { + meterProvider = SdkMeterProvider.builder() + .setResource(resource) + .registerMetricReader(PeriodicExportingMetricReader.builder(exporters.metric) + .setInterval(30, TimeUnit.SECONDS).build()) + .build(); + } + if (exporters.log != null && config.logEnabled() && ObservabilityRuntime.isLogEnabled()) { + loggerProvider = SdkLoggerProvider.builder() + .setResource(resource) + .addLogRecordProcessor(BatchLogRecordProcessor.builder(exporters.log).build()) + .build(); + } + OpenTelemetrySdk.Builder builder = OpenTelemetrySdk.builder(); + if (tracerProvider != null) { + builder.setTracerProvider(tracerProvider); + } + if (meterProvider != null) { + builder.setMeterProvider(meterProvider); + } + if (loggerProvider != null) { + builder.setLoggerProvider(loggerProvider); + } + GlobalOpenTelemetry.set(builder.build()); + if (exporters.console) { + LOG.warn("a2at: no OTLP endpoint configured; using Console exporters (dev mode)"); + } + } + + private static Resource buildResource(A2ATObservabilityConfig config) { + Attributes.Builder attributes = Attributes.builder(); + attributes.put("service.name", config.serviceName() == null + ? A2ATObservabilityConfig.DEFAULT_SERVICE_NAME : config.serviceName()); + String version = ObservabilitySetup.class.getPackage() == null + ? null : ObservabilitySetup.class.getPackage().getImplementationVersion(); + attributes.put("service.version", version == null ? "dev" : version); + if (config.resolveAgentRole() != null) { + attributes.put("agent.role", config.resolveAgentRole()); + } + return Resource.getDefault().merge(Resource.create(attributes.build())); + } + + private static Exporters buildExporters(A2ATObservabilityConfig config) { + Exporters exporters = new Exporters(); + if (config.endpoint() == null) { + boolean consoleEnabled = switchOn(A2ATObservabilityConfig.ENV_CONSOLE_EXPORTER); + if (!consoleEnabled) { + LOG.info("a2at: console exporter disabled and no OTLP endpoint; signals stay no-op"); + return exporters; + } + exporters.span = ConsoleExporters.spanExporter(); + exporters.metric = ConsoleExporters.metricExporter(); + exporters.log = ConsoleExporters.logRecordExporter(); + exporters.console = true; + return exporters; + } + exporters.console = false; + if ("http/protobuf".equals(config.protocol())) { + String base = config.endpoint().replaceAll("/$", ""); + exporters.span = OtlpHttpSpanExporter.builder() + .setEndpoint(base + "/v1/traces").build(); + exporters.metric = OtlpHttpMetricExporter.builder() + .setEndpoint(base + "/v1/metrics").build(); + exporters.log = OtlpHttpLogRecordExporter.builder() + .setEndpoint(base + "/v1/logs").build(); + } else { + exporters.span = OtlpGrpcSpanExporter.builder() + .setEndpoint(config.endpoint()).build(); + exporters.metric = OtlpGrpcMetricExporter.builder() + .setEndpoint(config.endpoint()).build(); + exporters.log = OtlpGrpcLogRecordExporter.builder() + .setEndpoint(config.endpoint()).build(); + } + return exporters; + } + + private static boolean switchOn(String key) { + String raw = System.getProperty(key, System.getenv(key)); + if (raw == null) { + return true; + } + return switch (raw.trim().toLowerCase(java.util.Locale.ROOT)) { + case "1", "true", "yes", "on" -> true; + default -> false; + }; + } + + private static final class Exporters { + @Nullable SpanExporter span; + @Nullable MetricExporter metric; + @Nullable LogRecordExporter log; + boolean console; + } +} +``` + +(`Exporters` 为可变静态内部类——record 不可变与逐字段赋值冲突,故不用 record。) + +- [ ] **Step 4: 运行确认通过** — `mvn -q -pl a2a-t-observability test "-Dtest=ObservabilityLogsTest,ObservabilitySetupTest"`(若 OTLP exporter builder API 与 1.64 签名不符,按 javap 调整;console 路径不依赖 OTLP 类加载——但 configure 在 endpoint=null 时不触碰 OTLP 类,`Class.forName` 保护仅在 sdkAvailable 一层,OTLP 类加载发生在 buildExporters 的 http/grpc 分支,endpoint=null 不会加载 ✓)。 + +- [ ] **Step 5: 提交** — `mvn -q spotless:apply; git add a2a-t-observability; git commit -m "feat(observability): add structured logs, console exporters and OTel auto-setup"` + +--- + +### Task 10: 门面接线(A2ATClient / A2ATServer)+ env.example + 全量回归 + +**Files:** +- Modify: `a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java` +- Modify: `a2a-t-server/src/main/java/net/openan/a2at/sdk/server/A2ATServer.java` +- Modify: `env.example`(追加可观测性小节) +- Test: `a2a-t-client/src/test/java/net/openan/a2at/sdk/client/api/A2ATClientObservabilityTest.java`、`a2a-t-server/src/test/java/net/openan/a2at/sdk/server/api/A2ATServerObservabilityTest.java` + +**Interfaces:** +- Consumes: Task 2 `TelemetryHook`/`TelemetryOperation`。 +- Produces: + - `A2ATClient(Path envPath, @Nullable TelemetryHook hook)` 新二级构造器;既有 `A2ATClient(Path)` 委托 `NO_OP`。字段 `private final TelemetryHook hook;`。 + - `A2ATServer(Path envPath, @Nullable TelemetryHook hook)` 同构。 + - 包私有 `static @Nullable String terminalOutcome(NegotiationStatus status)`:`AGREED→"accept"`、`REJECTED→"reject"`、其余 null(client/server 各一份 4 行副本,因 observability 模块不依赖 negotiation)。 + - 门面全部公开方法经 `observed(...)` 包 Span:名 `a2at.sdk.client.{method}` / `a2at.sdk.server.{method}`;成功 `markSuccess()`,RuntimeException 路径 `markError` + 原样抛。 + - `continueNegotiation(NegotiationContext, NegotiationStatus, String)` 成功后按 `terminalOutcome(status)` 回调 `hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round())`。 + +**方法清单(必须全部包装,逐个修改,不得遗漏):** +- client(24):`generateTaskPrompt`、`generateTaskPromptFromText`、`generateTaskPromptFromDataWithSchema`、`generateAuthPromptFromText`、`generateAuthPromptFromDataWithSchema`、`generateNotificationPromptFromText`、`generateNotificationPromptFromDataWithSchema`、`startNegotiation`、`receiveNegotiation`、`continueNegotiation`、`generateNegotiationProposePromptFromData`、`generateNegotiationAcceptPromptFromData`、`generateNegotiationRejectPromptFromData`、`generateNegotiationAbortPromptFromData`、`generateNegotiationProposePromptFromText`、`generateNegotiationAcceptPromptFromText`、`generateNegotiationRejectPromptFromText`、`generateNegotiationAbortPromptFromText`、`getPrompts`、`getPrompt`、`validateProposePromptAndDataFilling`、`validateAcceptPromptAndDataFilling`、`validateRejectPromptAndDataFilling`、`validateAbortPromptAndDataFilling` +- server(21):`checkTaskPrompt`、`startNegotiation`、`receiveNegotiation`、`continueNegotiation`、`generateNegotiationProposePromptFromData`、`generateNegotiationAcceptPromptFromData`、`generateNegotiationRejectPromptFromData`、`generateNegotiationAbortPromptFromData`、`generateNegotiationProposePromptFromText`、`generateNegotiationAcceptPromptFromText`、`generateNegotiationRejectPromptFromText`、`generateNegotiationAbortPromptFromText`、`getPrompts`、`getPrompt`、`validateProposePromptAndDataFilling`、`validateAcceptPromptAndDataFilling`、`validateRejectPromptAndDataFilling`、`validateAbortPromptAndDataFilling`、`validateTaskPromptAndDataFilling`、`validateNotificationPromptAndDataFilling`、`validateAuthPromptAndDataFilling` + +- [ ] **Step 1: 失败测试** + +```java +package net.openan.a2at.sdk.client.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import net.openan.a2at.sdk.client.A2ATClient; +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import net.openan.a2at.sdk.llm.LLMClient; +import net.openan.a2at.sdk.llm.LLMClientFactory; +import net.openan.a2at.sdk.llm.LLMResponse; +import net.openan.a2at.sdk.negotiation.types.model.NegotiationStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class A2ATClientObservabilityTest { + + private static final String MOCK_PROVIDER = "a2at-observability-mock"; + + static final class NoopLLM implements LLMClient { + @Override + public LLMResponse structured( + List> messages, + java.util.Map schema, Double temperature, Integer maxTokens) { + return new LLMResponse("{}", "mock", java.util.Map.of(), java.util.Map.of()); + } + } + + static final class RecordingHook implements TelemetryHook { + final List started = new ArrayList<>(); + final List succeeded = new ArrayList<>(); + final List rounds = new ArrayList<>(); + + @Override + public TelemetryOperation startSdkOperation(String role, String method) { + String name = role + "." + method; + started.add(name); + return new TelemetryOperation() { + @Override public void setAttribute(String key, Object value) {} + @Override public void markSuccess() { succeeded.add(name); } + @Override public void markError(Throwable error) { started.add("FAILED:" + name); } + @Override public void close() {} + }; + } + + @Override + public void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds) { + rounds.add(negotiationId + ":" + outcome + ":" + totalRounds); + } + } + + @TempDir + Path tempDir; + + private A2ATClient newClient(TelemetryHook hook) throws IOException { + if (!LLMClientFactory.availableProviders().contains(MOCK_PROVIDER)) { + LLMClientFactory.register(MOCK_PROVIDER, NoopLLM.class); + } + Path envFile = tempDir.resolve("client.env"); + Files.writeString(envFile, """ + A2AT_LANGUAGE=zh-CN + A2AT_LLM_PROVIDER=%s + A2AT_LLM_MODEL=example-model + A2AT_LLM_BASE_URL=https://llm.example.test/v1 + A2AT_LLM_API_KEY=test-key + A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + """.formatted(MOCK_PROVIDER)); + return new A2ATClient(envFile, hook); + } + + @Test + void llmFreePathIsWrappedAsSdkOperation() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + client.getPrompts(); + assertTrue(hook.started.contains("client.getPrompts")); + assertTrue(hook.succeeded.contains("client.getPrompts")); + } + + @Test + void runtimeFailureMarksErrorAndPropagates() throws IOException { + RecordingHook hook = new RecordingHook(); + A2ATClient client = newClient(hook); + assertThrows(NullPointerException.class, () -> client.generateTaskPromptFromText(null, null)); + assertTrue(hook.started.contains("client.generateTaskPromptFromText")); + assertTrue(hook.started.contains("FAILED:client.generateTaskPromptFromText")); + } + + @Test + void legacyConstructorUsesNoOpHook() throws IOException { + A2ATClient client = newClient(TelemetryHook.NO_OP); + assertEquals(client.getPrompts().size(), client.getPrompts().size()); + } + + @Test + void terminalOutcomeMapping() { + assertEquals("accept", A2ATClient.terminalOutcome(NegotiationStatus.AGREED)); + assertEquals("reject", A2ATClient.terminalOutcome(NegotiationStatus.REJECTED)); + assertEquals(null, A2ATClient.terminalOutcome(NegotiationStatus.IN_PROGRESS)); + } +} +``` + +server 测试 `A2ATServerObservabilityTest`:镜像以上结构(fixture env 同形、`new A2ATServer(env, hook)`、断言 `server.getPrompts`/`server.checkTaskPrompt` 起止、`A2ATServer.terminalOutcome` 映射)。server 包下既有测试(`A2ATServerNegotiationApiTest` 等)已有同型 env fixture 可参照。 + +- [ ] **Step 2: 运行确认失败** — `mvn -q -pl a2a-t-client test "-Dtest=A2ATClientObservabilityTest"`(编译错误:二级构造器/terminalOutcome 不存在)。 + +- [ ] **Step 3: 实现(A2ATClient)** + +1) 导入 + 字段 + 构造器改造: + +```java +import net.openan.a2at.sdk.core.telemetry.TelemetryHook; +import net.openan.a2at.sdk.core.telemetry.TelemetryOperation; +import org.jspecify.annotations.Nullable; + +public final class A2ATClient { + private final ClientPromptGenerationOrchestrator promptGenerationOrchestrator; + private final RoleBoundNegotiationOrchestrator negotiationOrchestrator; + private final NegotiationContentService negotiationContentService; + private final TemplateQueryService templateQueryService; + private final TelemetryHook hook; + + public A2ATClient(Path envPath) { + this(envPath, null); + } + + public A2ATClient(Path envPath, @Nullable TelemetryHook hook) { + Path resolvedEnvPath = envPath.toAbsolutePath().normalize(); + A2ATConfig config = + A2ATConfig.resolvePromptResourceLocalRootDir(A2ATConfig.load(resolvedEnvPath), resolvedEnvPath); + DefaultA2ATClientBuilder builder = + DefaultA2ATClientBuilder.builder().envPath(resolvedEnvPath).config(config); + this.hook = hook == null ? TelemetryHook.NO_OP : hook; + this.promptGenerationOrchestrator = builder.buildPromptGenerationOrchestrator(); + this.negotiationOrchestrator = builder.buildNegotiationOrchestrator(); + this.negotiationContentService = + new NegotiationContentService(builder.buildNegotiationGenerationOrchestrator()); + this.templateQueryService = builder.buildTemplateQueryService(); + } + + private T observed(String method, java.util.function.Supplier action) { + try (TelemetryOperation operation = hook.startSdkOperation("client", method)) { + try { + T result = action.get(); + operation.markSuccess(); + return result; + } catch (RuntimeException error) { + operation.markError(error); + throw error; + } + } + } + + static @Nullable String terminalOutcome(NegotiationStatus status) { + if (status == NegotiationStatus.AGREED) { + return "accept"; + } + return status == NegotiationStatus.REJECTED ? "reject" : null; + } + + private void reportNegotiationRounds(NegotiationContext context, NegotiationStatus status) { + try { + String outcome = terminalOutcome(status); + if (outcome != null) { + hook.reportNegotiationRounds(context.negotiationId(), outcome, context.round()); + } + } catch (RuntimeException error) { + LOGGER.warn("a2at: negotiation metric report failed", error); + } + } +} +``` + +(client 现无 LOGGER 则加 `private static final org.slf4j.Logger LOGGER = org.slf4j.LoggerFactory.getLogger(A2ATClient.class);`) + +2) 方法包装(保持既有方法体为 lambda 载荷,逐个改写;三个代表样例): + +```java +public PromptGenerationResult generateTaskPrompt(Object userInput) { + return observed("generateTaskPrompt", () -> promptGenerationOrchestrator.generateTaskPrompt(userInput)); +} + +public Map continueNegotiation( + NegotiationContext context, NegotiationStatus status, String contentText) { + return observed("continueNegotiation", () -> { + Map result = negotiationOrchestrator.continueNegotiation(context, status, contentText); + reportNegotiationRounds(context, status); + return result; + }); +} + +public Optional getPrompt(@NonNull String templateUri) { + return observed("getPrompt", () -> templateQueryService.findTemplate(TemplateUri.of(templateUri))); +} +``` + +其余 21 个方法按同样模式改写(把现有方法体整体放入 `observed("", () -> )`;`getPrompts` 等无参方法同样处理)。**禁止改动任何业务语义**——只做包装。 + +3) A2ATServer 同构实现:role 用 `"server"`,24→21 个方法按清单包装,`continueNegotiation` 同样接 `reportNegotiationRounds`,`terminalOutcome` 副本同名。 + +- [ ] **Step 4: 运行确认通过 + 全量回归** + +``` +mvn -q -pl a2a-t-client test "-Dtest=A2ATClientObservabilityTest" +mvn -q -pl a2a-t-server test "-Dtest=A2ATServerObservabilityTest" +mvn -q test # 全模块回归(corpus/sample 在内) +``` + +期望:全部 PASS,既有测试零回归。 + +- [ ] **Step 5: env.example 追加可观测性小节** + +``` +# --- Observability (a2a-t-observability) --- +OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED=true +A2AT_OBSERVABILITY_ENABLED=true +A2AT_TRACE_ENABLED=true +A2AT_METRIC_ENABLED=true +A2AT_LOG_ENABLED=true +#OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +A2AT_EXPORTER_PROTOCOL=grpc +A2AT_CONSOLE_EXPORTER=true +#OTEL_SERVICE_NAME=a2a-t-agent +#A2AT_AGENT_ROLE= +A2AT_EXTRACT_REQUEST=false +A2AT_EXTRACT_RESPONSE=false +A2AT_LOGS_PAYLOAD_ENABLED=false +A2AT_LOGS_PAYLOAD_MAX_LENGTH=4096 +``` + +- [ ] **Step 6: 提交** — `mvn -q spotless:apply; git add a2a-t-client a2a-t-server env.example; git commit -m "feat(observability): wire TelemetryHook into client and server facades"` + +--- + +## Plan A 自检清单(完成后执行) + +- [ ] spec §3 表 12 行 → 逐行对应类已存在且被测试覆盖(`attributes.py` 拆分说明见 Task 4;`sdk_trace.py` 的 `SdkOperationSpans` OTel 实现移入 Plan B,因其属于可选实现侧) +- [ ] spec §5 命名注册表 → Task 4 常量 + Task 8 指标名 + Task 9 事件名逐字一致 +- [ ] spec §7 配置表 → Task 3 常量 + Task 5 开关 + Task 9 console/env 处理 +- [ ] spec §8 三条铁律 → Task 9 幂等/降级、Task 10 try/catch 包装、A2ATSpan scope close +- [ ] spec §9 测试策略 → 各 Task 测试 + Task 10 全量回归 +- [ ] `mvn -q spotless:check` 全绿;NullAway 零报错 + +## 后续(Plan B,另行编写) + +a2a-java 协议层装饰器(`A2ATClientTransportDecorator`、`A2ATRequestHandlerDecorator`、`A2ATAgentExecutorDecorator`、`A2ATPushSenderDecorator`、`A2ATA2ATClientTransportWrapper`、类型化提取器 `A2AJavaAttributeExtractors`、`SdkOperationSpans` hook 实现)+ sample 可观测场景 + README。待 Plan A 落地后按其真实签名编写独立计划。 + +### Plan B 必须吸收的终审遗留项(final review 2026-10-20) + +1. `ensureConfigured()` 仅读系统环境变量;`.env` 接线(`fromMap(DotEnvConfigSource.load(path))`)由 Plan B 的 README/sample 负责,并注意 `A2AT_SERVICE_NAME` 未列入 env.example 示例 +2. `PayloadSanitizer` redactor 失败时 fail-open(透传未脱敏值)→ 后续安全加固改 fail-closed(丢弃 payload) +3. `MetricHistograms` 静态缓存跨 `GlobalOpenTelemetry.resetForTest()` 存活 + 装配前缓存的直方图永久绑定 no-op meter → Plan B 设计 provider 感知缓存或在 `ObservabilitySetup.resetForTest` 中清缓存 +4. 终审已修:门面 hook 回调全防护(ae2c526);`getOrNoop()` 防 get() 副作用(ae2c526)——Plan B 的 `SdkOperationSpans` 必须沿用 `getOrNoop()` 模式且自身调用点全部 try/catch 防护;**`TelemetryOperation.close()` 是最后一个未防护 hook 点**(成功路径 try-with-resources 的 close 在 return 后触发,用户实现抛异常会把业务成功变失败)——Plan B 实现的 close() 必须自防护,或后续在门面 observed() 内补 guard +5. 遗留 follow-up(不阻塞合并):console-exporter-disabled 分支跳过 `set()`、ISE 路径 provider 关闭、`replaceAll("/+$")` 多斜杠、`switchOn` 去重、`A2ATCurrentSpan.setAttribute` 类型化 + + + + diff --git a/docs/superpowers/specs/2026-10-20-observability-java-port-design.md b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md new file mode 100644 index 00000000..45ec14da --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-observability-java-port-design.md @@ -0,0 +1,247 @@ +# A2A-T Java SDK 可观测性设计(Python 版移植) + +> **版本**: v1.0 +> **日期**: 2026-10-20 +> **定位**: 将 `a2a-t-sdk-python` 的可观测模块(v3.0 装饰器架构)忠实移植到 `a2a-t-sdk-java` +> **范围决策**: 完全对齐 Python(SDK 内部插桩 + a2a-java 协议层装饰器);OTel 依赖采用 api 编译 + sdk/exporter 可选;双模块拆分;日志 JSON 行格式 + +--- + +## 1. 背景与目标 + +Python 版可观测模块(参照 `a2a-t-sdk-python/observity.md` 与 `src/a2a_t/observability/`)提供三大信号: + +- **Trace**:协议层入口 Span(`SendMessage`/`GetTask` 等)、SSE per-event Span(同 trace 孤儿上下文 + Link)、W3C traceparent 传播 +- **Metrics**:`gen_ai.client.operation.duration`、`gen_ai.client.token.usage`、`a2at.task.request.duration`(双端各记)、`a2at.negotiation.total_rounds` +- **Logs**:结构化事件(`task.request`、`task.status_changed`、`task.artifact`、`negotiation.message` 等) + +### 1.1 Java 版的关键差异 + +| 差异点 | Python | Java | +|---|---|---| +| A2A 协议层归属 | SDK 内含(装饰 a2a-sdk 的 `ClientTransport`/`RequestHandler`) | SDK 模块**不含** a2a-java(仅 sample 依赖);协议层插桩放入独立可选模块 | +| 鸭子类型 | observability 层用 `getattr` 结构式探测,零编译期 a2a 依赖 | Java 无法 duck-type,协议层模块必须编译期依赖 a2a-java 1.1.0.Final | +| 门面插桩 | `trace_facade()` 实例方法动态包装 | 门面为 final 类且 SDK 全同步,改为 `TelemetryHook` SPI + builder `@Nullable` 注入(沿用 `llmClient(@Nullable ...)` 惯例) | +| NoOp 降级 | 手写 `_NoOpTracer` 等类 | OTel API 天然 no-op(未装 SDK 时 `GlobalOpenTelemetry` 返回 no-op),只需开关短路 | +| LLM 用量桥接 | contextvar stash | ThreadLocal(SDK 全同步,无协程切换问题) | +| 客户端流式 | async generator | 回调式 `Consumer` | +| 服务端流式 | EventQueue 装饰 | `Flow.Publisher` 包装 | +| 日志 | Python logging JSON record(LoggingHandler 桥接 OTLP logs) | SLF4J + Jackson 序列化 JSON 行(**决策:JSON 而非仓库 key=value 惯例,对齐后端摄入管道**)+ OTel Logger API 双通道(§3) | + +### 1.2 目标 + +- Span/Span 属性/Metric/Log 事件命名与 Python 版**逐字一致**,保证仪表盘查询零改动 +- 永不破坏业务流:所有插桩点异常吞并 + WARN +- OTel 未安装 / 开关关闭时全链路 pass-through +- 核心 SDK 模块对 OTel 与 a2a-java 保持零依赖 + +## 2. 模块结构(方案 A,已确认) + +``` +a2a-t-observability 核心:配置/装配/手动 API/传播/日志/指标/LLM 装饰器 + 依赖: a2a-t-core, a2a-t-llm, + io.opentelemetry:opentelemetry-api (compile), + opentelemetry-sdk + opentelemetry-exporter-otlp + + opentelemetry-exporter-logging (true) + +a2a-t-observability-a2a 协议层装饰器(可选模块) + 依赖: a2a-t-observability, + org.a2aproject.sdk:a2a-java-sdk-client-transport-spi / -server-common / -spec / -jsonrpc-common + / -transport-rest (1.1.0.Final,RestContextKeys.HEADERS_KEY 提取 headers) +``` + +- 两模块登记进根 `pom.xml` `` 与 `a2a-t-bom` +- 包名:`net.openan.a2at.sdk.observability`(及 `net.openan.a2at.sdk.observability.a2a`),包级 `@NullMarked` +- a2a-java 版本 1.1.0.Final(本地 `.m2` 已有;含 `ClientTransportWrapper` SPI、抽象类 `OpenTelemetryRequestHandlerDecorator` 先例、`PushNotificationSender`、`EventEnqueueHook`) + +### 2.1 既有模块改动(最小侵入) + +| 模块 | 改动 | +|---|---| +| `a2a-t-core` | 新增 `net.openan.a2at.sdk.core.telemetry.TelemetryHook` SPI 接口 + `NO_OP` 常量(见 §4) | +| `a2a-t-client` | `A2ATClient` 新增二级构造器 `A2ATClient(Path envPath, @Nullable TelemetryHook hook)`(既有构造器委托 NO_OP);全部公开方法经 hook 包 `a2at.sdk.client.{method}` Span;`continueNegotiation` 终态时回调 `reportNegotiationRounds` | +| `a2a-t-server` | 同上(`A2ATServer(Path, @Nullable TelemetryHook)`,role=server) | +| `a2a-t-bom` / 根 pom | 登记两新模块与依赖管理 | + +> 注:hook 注入点在**门面二级构造器**而非 builder——builder 只组装 orchestrator(非门面),hook 生命周期属于门面方法包装;与 §4 `trace_facade` 等价机制一致。 + +依赖方向保持单向:`observability → core/llm`,`observability-a2a → observability + a2a-java`。**`ObservabilityLLMClient` 由使用者/sample 组装时显式包装**(builder 不感知 OTel),文档给出示例。 + +## 3. 核心模块 `a2a-t-observability` 内部设计 + +与 Python `src/a2a_t/observability/` 文件一一对应: + +| Python | Java 类 | 要点 | +|---|---|---| +| `config.py` | `A2ATObservabilityConfig` | record + builder + `fromMap(DotEnvConfigSource)`;显式 > 环境变量 > 默认 | +| `setup.py` | `ObservabilitySetup` | `ensureConfigured()`:自动装配 `SdkTracerProvider`/`SdkMeterProvider`/`SdkLoggerProvider` + `BatchSpanProcessor`/`PeriodicExportingMetricReader`/`BatchLogRecordProcessor`(**三信号**,对齐 Python);OTLP gRPC(默认)或 HTTP/protobuf(endpoint 自动拼 `/v1/traces`、`/v1/metrics`、`/v1/logs`;Java `opentelemetry-exporter-otlp` 单 artifact 含三种 exporter);无 endpoint 降级 Console(`opentelemetry-exporter-logging` 的 `LoggingSpanExporter`/`LoggingMetricExporter`/`LoggingLogRecordExporter`);幂等(Java 无 Proxy provider 概念:内部 once 标志 + `GlobalOpenTelemetry.set()` 已设置时抛 `IllegalStateException` 捕获后跳过并 DEBUG);`sdk` 类缺失时 `Class.forName` 探测跳过自动装配 | +| `_otel_compat.py` | `ObservabilityRuntime` | 总开关 `OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED`、tracer/meter 访问器(scope `a2at-observability`/`1.0.0`)、trace/metric/log 三信号独立开关、`sameTraceOrphanContext`(同 trace 随机 span_id 无父上下文 + 0 值守卫) | +| `span.py` | `A2ATSpan implements AutoCloseable` | try-with-resources;kind 枚举 `INTERNAL/CLIENT/SERVER/PRODUCER/CONSUMER`;`setAttribute`/`addLink(A2ATSpan)`。**异常语义**(Java try-with-resources 的 close() 收不到业务异常,与 Python `__exit__(exc)` 不同):增加 `recordError(Throwable)` 方法,惯用法在 catch 中调用;`close()` 按状态收尾——已 `recordError` → record_exception + ERROR,否则 OK。**若使用者既未调用 recordError 又发生异常,Span 以 UNSET 结束**(内部装饰器不经过此路径,状态语义由 §6 的显式 try/finally 全量保证) | +| `current_span.py` | `CurrentSpans.current()` → `A2ATCurrentSpan` | 只读视图:`traceId()`/`spanId()`/`isValid()`/`setAttribute()`;非 recording 时返回空 | +| `attributes.py` | `A2ATAttributes` + `AttributeExtractors` | 全部属性常量 + 提取器(§5 命名注册表) | +| `propagation.py` | `TracePropagation` | `inject(Map)` / `extract(Map)`;W3C traceparent;key 小写容错;extract 返回 null 当 span context 无效 | +| `payload.py` | `PayloadExtractor` + `PayloadSanitizer` | **提取**:a2a-java spec 对象(Jackson 注解 POJO)经 Jackson 序列化为 JSON(对齐 Python `MessageToDict`);**净化**:先脱敏后截断(安全顺序);默认 4096 + `[truncated]` 后缀;自定义 redactor 函数接口 | +| `logs.py` | `ObservabilityLogs` | SLF4J logger `net.openan.a2at.sdk.observability`;**JSON 行**(Jackson 序列化,`ensure_ascii=false` 等价):`{"event": ..., <业务字段>, "trace_id", "span_id", "payload"?}`;事件名沿用 Python;log_enabled 双门控(运行时开关 + config);**OTel 日志信号**:log_enabled 时同步写入 OTel `Logger` API(`SdkLoggerProvider` + OTLP/Console exporter),SLF4J 与 OTel 双通道共享同一 JSON 语义,对齐 Python LoggingHandler 桥接 | +| `llm_decorator.py` + `llm_stash.py` | `ObservabilityLLMClient implements LLMClient` + `LlmUsageStash` | `structured()` 返回后:从 `LLMResponse.usage`(键 `prompt_tokens`/`completion_tokens`,与 Python stash 读法一致)+ `model()` 取数;记 `gen_ai.client.token.usage` 直方图(unit `{token}`,按 `gen_ai.token.type`=input/output 各一样本)+ `LlmUsageStash`(ThreadLocal)暂存(input/output tokens、request model)供协议层入口 Span 取用(**取后清空**;trace 关闭时入口侧直接丢弃 stash) | +| `negotiation_metrics.py` | `NegotiationMetrics` | `a2at.negotiation.total_rounds` 直方图(unit `{round}`);属性 `gen_ai.agent.a2at.extension.name="Negotiation-T"`、`negotiation.id`、`outcome`(accept/reject/abort);仅终态触发;outcome 映射:Java `NegotiationStatus` 仅 `IN_PROGRESS/AGREED/REJECTED`(无 ABORT)→ `AGREED→accept`、`REJECTED→reject`(Python 的 abort 在 Java 无对应,不产生样本);**数据路径差异**:Python 从返回的 `MetadataContent.negotiation_context` 取,Java 从门面入参/结果的 `NegotiationContext(type, negotiationId, round, NegotiationStatus)` 取(终态 status 映射 outcome),结果等价 | +| `sdk_trace.py` | `SdkOperationSpans` | `TelemetryHook` 的 OTel 实现:`a2at.sdk.{role}.{method}` INTERNAL Span | + +指标 instrument 缓存沿用 Python 的惰性单例模式(`ConcurrentHashMap`)。 + +## 4. `TelemetryHook` SPI(a2a-t-core) + +```java +package net.openan.a2at.sdk.core.telemetry; + +public interface TelemetryHook { + TelemetryOperation startSdkOperation(String role, String method); + void reportNegotiationRounds(String negotiationId, String outcome, long totalRounds); + + TelemetryHook NO_OP = new TelemetryHook() { ... }; // 全 no-op +} +``` + +- `TelemetryOperation`:AutoCloseable(try-with-resources 结束 Span)+ `setAttribute(String, Object)`;调用方在业务成功后先调 `markSuccess()` 再 close(close 时若未 markSuccess 且无异常标记则记 ERROR——防御性默认,内部调用点全部受控、两条路径必走其一),异常路径由 try/catch 中的 `markError(Throwable)` 标记。与手动 API `A2ATSpan` 的差异:后者面向使用者,无法感知未上报的异常,未记录时 UNSET 结束(§3),两者语义各自明确 +- 调用点一律 try/catch 包裹 + WARN,业务异常原样抛出 +- `startSdkOperation` 等价 Python `trace_facade`(opt-in L4):注入 hook 后每个门面方法包 Span,名 `a2at.sdk.client.generateTaskPrompt` / `a2at.sdk.server.checkTaskPrompt` 等 + +## 5. 命名注册表(与 Python 逐字一致) + +### 5.1 Span 名(kind) + +| Span 名 | kind | 端 | +|---|---|---| +| `SendMessage` / `SendStreamingMessage` | CLIENT / SERVER | 双端 | +| `SendStreamingMessage-event` | CLIENT / SERVER | 双端(同 trace 孤儿上下文 + Link 指向入口 Span) | +| `SendStreamingMessage-error` | CLIENT | 错误回调(`error.type` 属性;取消类异常豁免) | +| `SendMessage-negotiation` / `SendStreamingMessage-negotiation` | CLIENT / SERVER | 双端,协商 Message 触发(**父子**挂入口 Span;client 由响应/事件观测发,server 流式由 Publisher 侧发、sync 由收尾阶段发,避免双发) | +| `SubscribeToTask` / `GetTask` / `ListTasks` / `CancelTask` | CLIENT / SERVER | 双端 | +| `CreateTaskPushNotificationConfig` / `GetTaskPushNotificationConfig` / `ListTaskPushNotificationConfigs` / `DeleteTaskPushNotificationConfig` | CLIENT / SERVER | 双端 | +| `GetExtendedAgentCard` | CLIENT / SERVER | 双端 | +| `SendMessage-pushNotification` | CLIENT | PushSender 装饰器(Link 入口 Span) | +| `a2at.sdk.{role}.{method}` | INTERNAL | SDK 门面(opt-in) | + +流结束判定:终态事件(`completed`/`failed`/`canceled`/`rejected`)。状态语义:取消 → ERROR "cancelled";其他异常 → record_exception + ERROR;成功 → OK。 + +### 5.2 属性 + +- A2A-T 扩展(前缀 `gen_ai.agent.a2at.`):`extension.name`(由扩展 URI 解析,基址 `https://projects.tmforum.org/a2aproject/telecommunication/extensions/` → `Negotiation-T` 等)、`task.id`、`task.status`、`task.type`、`negotiation.id/round/max_rounds/performative/total_rounds`、`notification.topic`、`streaming.event.kind`、`push.notification.url`、`authorization.policy.operation.type` +- 预授权(无前缀,对齐 Python):`authorization.policy.id`、`authorization.operation_type`、`authorization.operation_risk_level` +- a2a-java 仪表盘双写(前缀 `gen_ai.agent.a2a.`,与 `A2AObservabilityNames` 对齐):`operation.name`、`context_id`、`task_id`、`message_id`、`role`、`extensions`、`parts.number`、`protocol`、`request`、`response` +- GenAI semconv:`gen_ai.operation.name`、`gen_ai.conversation.id`、`gen_ai.usage.input_tokens/output_tokens`、`gen_ai.request.model`、`gen_ai.response.model`、`gen_ai.token.type`、`gen_ai.provider.name`、`streaming`、`error.type` +- 侧别:`a2at.span.side` = `client` | `server` +- 报文正则提取(默认值照搬 Python):任务类型 `##\s*任务类型\(Task Type\)`、通知主题 `##\s*通知主题`、授权操作类型 `##\s*授权策略的操作类型` + +### 5.3 指标 + +| 指标 | 类型/unit | 属性 | +|---|---|---| +| `gen_ai.client.operation.duration` | Histogram, s | `gen_ai.operation.name`(client 端) | +| `gen_ai.client.token.usage` | Histogram, `{token}` | `gen_ai.token.type`=input/output | +| `a2at.task.request.duration` | Histogram, s | `gen_ai.operation.name` + `a2at.span.side`(双端各记) | +| `a2at.negotiation.total_rounds` | Histogram, `{round}` | `extension.name` + `negotiation.id` + `outcome` | + +### 5.4 日志事件 + +`task.request`(DEBUG)、`task.response`(DEBUG)、`task.status_changed`(INFO)、`task.artifact`(INFO)、`notification.push`(INFO)、`negotiation.message`(DEBUG);字段:`task.id`、`task.status`、`artifact.name`、`extension.name`、`streaming.event.kind`(notification.push 携带)、`negotiation.id/round/performative`(negotiation.message 携带)、`trace_id`、`span_id`、`payload?`。 + +## 6. 协议层 `a2a-t-observability-a2a` 设计 + +### 6.1 Client:`A2ATClientTransportDecorator implements ClientTransport` + +- 覆写全部 12 方法;入口 Span kind=CLIENT,名见 §5.1;**构造器防双重装饰守卫**(内层已是本装饰器则解包,杜绝双重 Span,对齐 Python factory-guard) +- **传播**:从 `ClientCallContext.getHeaders()`(小写容错)提取调用方 traceparent 为父(业务根传播 mode B);无则显式 ROOT(`SpanContext` INVALID 语义等价物);向 delegate 传递**新建** `ClientCallContext`(原 state + 注入入口 Span traceparent 的 headers,不改传入对象 —— 相比 Python 原地覆写 `service_parameters` 更安全,消除"覆写调用方业务根"缺陷) +- **流式**(`sendMessageStreaming`/`subscribeToTask`):包装两个 Consumer;每事件发 `SendStreamingMessage-event`(同 trace 孤儿上下文 + Link + `gen_ai.conversation.id`);错误 Consumer 发 `SendStreamingMessage-error`(`error.type`);**取消/中止类异常(`CancellationException`)不发 error span**(对齐 Python CancelledError 豁免)。**注意**:Java 回调式流式模型下装饰器只观测、不可中断内层流——"终态事件"仅用于内部判定流尾(记录 final_event 供响应属性/`task.response` 日志),流的终止由 a2a-java 客户端层控制(与 Python 生成器 `break` 的能力差异,行为等价性由 a2a-java 保证) +- **协商 Span(client 侧)**:sync 响应与流式事件中的协商 Message 发 `{操作名}-negotiation`(CLIENT kind,**父子**挂入口 Span,即时结束;属性 `gen_ai.operation.name`/`extension.name`/`gen_ai.conversation.id`/`task.id`/`negotiation.*`)+ `negotiation.message` 日志 +- **属性**:请求结构化提取 + 双写 + stash 中 LLM token 用量 + 正则提取(DEFAULT_* 回退、空串 opt-out、尾括号清洗);**trace 关闭时 stash 必须丢弃**(防泄漏到下一请求入口 Span,对齐 Python) +- **指标**:`gen_ai.client.operation.duration` + `a2at.task.request.duration`(side=client),双门控(运行时开关 + config 实例开关) +- 另提供 `A2ATA2ATClientTransportWrapper implements ClientTransportWrapper`(a2a-java 原生 SPI,可注册自动包装,等价 Python `register_client_factory`) + +### 6.2 Server:`A2ATRequestHandlerDecorator implements RequestHandler` + +- `onMessageSend`→`SendMessage`;`onMessageSendStream`→`SendStreamingMessage`(Span 存活至流结束,try/finally);`onSubscribeToTask`→`SubscribeToTask`(仅入口,不重复观测——事件由产生请求侧观测);其余简单包装;kind=SERVER +- **父上下文**:`ServerCallContext.getState()` 按 `RestContextKeys.HEADERS_KEY`(transport-rest)提取 headers,找不到时通用探测 `headers` key(小写容错,兼容 jsonrpc transport) +- **流式 per-event**:包装返回的 `Flow.Publisher`(订阅者包装,**仅流式操作**——同步 `SendMessage` 不产生幻影 event Span,对齐 Python `_start_event_span` 门);每事件 `SendStreamingMessage-event` SERVER Span(孤儿 + Link);协商 Message 事件发 `SendStreamingMessage-negotiation`(**父子**)+ `negotiation.message` 日志 +- **同步 `SendMessage` 的协商 Span**:响应分类后在收尾阶段发(对齐 Python `_capture_response`,EventQueue/Publisher 侧只管流式,避免双发) +- **入口 Span 注册表**:有界 map(上限 2048,FIFO 淘汰),task_id 与 context_id 双 key 注册;**context_id fallback 仅当该记录的入口 Span 仍在 recording 时可被采纳**(防止复用上一请求的记录,对齐 Python `_find_record`) +- **指标**:`a2at.task.request.duration`(side=server,双门控) + +### 6.3 协作装饰器 + +- `A2ATAgentExecutorDecorator implements AgentExecutor`:激活入口 Span(业务代码可见 `current_span()`)。用法:**先包 executor 再 `DefaultRequestHandler.create(...)`,后包 handler**(create 显式收 executor,无需反射) +- `A2ATPushSenderDecorator implements PushNotificationSender`:`SendMessage-pushNotification` CLIENT Span + Link 入口 Span + `push.notification.url` 属性。**注入点**:a2a-java 的 push 发送经 `MainEventBusProcessor(MainEventBus, TaskStore, PushNotificationSender, QueueManager)` 公共构造器注入 → 先装饰 sender 再构造。**URL 提取**:a2a-java `sendNotification(event, task)` 不携带 URL(URL 在内部私有 dispatch 中);装饰器持有 `PushNotificationConfigStore`,发 Span 时按 task_id 查询配置取 URL(对齐 Python 经 `push_info.url` 的语义) +- `PayloadSanitizer` 复用核心模块;payload 提取(request/response JSON)受 `A2AT_EXTRACT_REQUEST/RESPONSE` 门控;span 属性通道(`gen_ai.agent.a2a.request/response`)与日志通道(`task.request`/`task.response`)共享同一 redact→truncate 语义 + +### 6.4 与 a2a-java 自带 OTel 插桩的关系 + +**二选一,不叠加**:使用本模块装饰器时,不得同时注册 `OpenTelemetryClientTransportWrapper` / `OpenTelemetryClientPropagatorTransportWrapper` / `OpenTelemetryRequestHandlerDecorator`,否则双重 Span。README 明示。 + +## 7. 配置 + +| 键 | 默认 | 说明 | +|---|---|---| +| `OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED` | `true` | 总开关,false → 全 no-op | +| `A2AT_OBSERVABILITY_ENABLED` | `true` | 装饰器门闸,false → 装饰器纯转发 | +| `A2AT_TRACE_ENABLED` / `A2AT_METRIC_ENABLED` / `A2AT_LOG_ENABLED` | `true` | 三信号独立(trace 关,metrics/logs 照常) | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | 无 | 无 → Console 降级 | +| `A2AT_EXPORTER_PROTOCOL` | `grpc` | `grpc` \| `http/protobuf`(别名 `http`;非法 → grpc + WARN) | +| `A2AT_CONSOLE_EXPORTER` | `true` | 关闭 Console 降级 | +| `OTEL_SERVICE_NAME` / `A2AT_SERVICE_NAME` | `a2a-t-agent` | Resource `service.name`;`A2AT_AGENT_ROLE` → Resource `agent.role`(opt-in) | +| `A2AT_EXTRACT_REQUEST` / `A2AT_EXTRACT_RESPONSE` | `false` | payload 上 Span 属性 | +| `A2AT_LOGS_PAYLOAD_ENABLED` | `false` | 日志 payload 通道 | +| `A2AT_LOGS_PAYLOAD_MAX_LENGTH` | `4096` | 截断上限(`[truncated]`) | + +读取顺序:`.env`(复用 core `DotEnvConfigSource`)→ 系统环境变量 → 默认值。`A2ATObservabilityConfig` 可编程构造传入各装饰器;正则可覆盖(空串 = opt-out)。懒装配:首次装饰调用时 `ensureConfigured()`。 + +## 8. 降级与错误处理(三条铁律) + +1. **永不破坏业务流**:插桩点 try/catch + WARN(logger `net.openan.a2at.sdk.observability`),业务异常原样抛出 +2. **全链路 pass-through**:OTel SDK 未安装 / 开关关 → 装饰器纯转发;OTel API 层天然 no-op 兜底 +3. **Span 激活防泄漏(Java 简化)**:SDK 全同步,`Span.makeCurrent()` 返回的 `Scope` 在同线程 close 即可靠恢复——不需要 Python 的快照+强制修复逻辑(§11#10);跨线程路径(executor 跑在 worker 线程、push 跑在 MainEventBusProcessor 线程)均在线程入口处显式激活/关闭,不依赖 ambient 跨线程继承 + +## 9. 测试策略 + +沿用仓库惯例:JUnit 5 + 手写 scripted stub + logback `ListAppender`;无 Mockito。 + +- **核心模块**:配置解析矩阵(显式/env/默认/非法值)、装配幂等与降级(无 endpoint → Console;无 sdk 类 → 跳过)、传播 inject/extract 往返与容错、Payload 脱敏→截断顺序、LLM 装饰器 token 指标与 stash 取后清空、协商指标终态触发 +- **协议层**:scripted `ClientTransport`/`RequestHandler`/`AgentExecutor`/`PushNotificationSender` fake + a2a-java spec 真实类型构造请求/事件;用 `SdkTracerProvider` + 内存 `SpanProcessor`(收集已结束 Span)断言:Span 名/kind/属性/状态/Link 拓扑/per-event 序列/双端指标;**双重装饰守卫**(装饰器套装饰器只出一层 Span);**取消豁免**(CancellationException 不发 error span);**context_id fallback 仅 recording 可采纳** +- **公共 API 导出面合同**(对齐 Python `test_public_api.py` 的 "nothing more, nothing less"),Java 导出面基准: + - 装配:`ObservabilitySetup.setup / isConfigured` + - 集成:`A2ATClientTransportWrapper`、`A2ATRequestHandlerDecorator`(+ Executor/PushSender 装饰器) + - 手动 API:`A2ATSpan`(try-with-resources)、`CurrentSpans.current()` / `A2ATCurrentSpan` + - 门面追踪:`TelemetryHook`(+ `SdkOperationSpans` 实现) + - 传播:`TracePropagation.inject/extract`、`TRACEPARENT_HEADER` + - 配置:`A2ATObservabilityConfig` + - 属性常量:§5.2 全量(A2A-T 扩展 14 个 + 预授权 3 个 + GenAI 7 个 + `a2at.span.side`/`streaming`/`error.type`) +- **日志 JSON 字段合同**(ListAppender + Jackson 解析断言 event/trace_id/span_id/payload) +- **既有模块回归**:builder `@Nullable` 注入点新增注入测试;全量 `mvn test` +- **Sample**:`a2a-t-sample` 新增 `observability` 示例场景(对标 Python `observability-sample`:Console 模式端到端跑通 trace/metrics/logs) + +## 10. 交付边界(不做的事) + +- 不做 Micrometer/Prometheus 桥、StatsD、Zipkin +- 不做 ByteBuddy/Java Agent 字节码插桩 +- 不做动态实例包装(Python `trace_facade` 的 Java 等价由 builder 注入 hook 实现) +- 不叠加 a2a-java 自带 OTel 插桩(二选一) +- 协议层模块不追求兼容 a2a-java 1.0.0.Beta1(锁定 1.1.0.Final) + +## 11. 可行性核查结论(2026-10-20,源码级验证) + +| # | 接缝 | 结论 | +|---|---|---| +| 1 | Client 12 方法装饰 + `ClientCallContext(state, headers)` 新建实例注入 traceparent | ✅ 可行 | +| 2 | Server 11 方法装饰 + `Flow.Publisher` 订阅者包装 per-event Span | ✅ 可行 | +| 3 | traceparent 提取:`RestContextKeys.HEADERS_KEY`(transport-rest 公共常量)+ 通用探测回退 | ✅ 可行(依赖清单已补 transport-rest) | +| 4 | PushNotificationSender 注入:`MainEventBusProcessor(MainEventBus, TaskStore, PushNotificationSender, QueueManager)` 公共构造器 | ✅ 可行(DefaultRequestHandler 不直接收 sender;先装饰再构造) | +| 5 | `push.notification.url`:`sendNotification(event, task)` 无 URL 参数 | ⚠️ 调整:装饰器持 `PushNotificationConfigStore` 按 task_id 查 URL(§6.3) | +| 6 | `A2ATAgentExecutorDecorator`:`DefaultRequestHandler.create` 显式收 executor | ✅ 先包后建,无需反射 | +| 7 | LLM token:`LLMResponse.usage` 键 `prompt_tokens`/`completion_tokens` + `model()` | ✅ 与 Python stash 读法一致 | +| 8 | 协商轮次指标:Java 从入参 `NegotiationContext` 取(Python 从返回值取) | ✅ 数据路径小异,结果等价 | +| 9 | 同 trace 孤儿上下文:`SpanContext.create(traceId, randomSpanId, flags, state)` + `spanBuilder.setParent` | ✅ 可行(含 0 值 span_id 守卫) | +| 10 | 防泄漏激活:Java SDK 全同步,`Span.makeCurrent()`/`Scope.close()` 同步可靠 | ✅ 简化(Python 的跨 async 上下文修复逻辑不需要) | + +## 12. 参考 + +- Python 实现:`a2a-t-sdk-python/src/a2a_t/observability/`(对照文件清单见 §3 表格;`__init__.py` 公共导出 24 符号为 API 面合同基准) +- Python 设计大纲:`a2a-t-sdk-python/observity.md` +- a2a-java 接缝(javap + 常量池扫描验证 1.1.0.Final):`ClientTransport`、`ClientTransportWrapper`、`ClientCallContext(Map state, Map headers)`、`RequestHandler`(流式返回 `Flow.Publisher`)、`ServerCallContext.getState()` + `RestContextKeys.HEADERS_KEY`、`AgentExecutor`、`PushNotificationSender`/`BasePushNotificationSender`(私有 `dispatch(event, task, TaskPushNotificationConfig, headers)` 含 URL)、`MainEventBusProcessor` 构造器、`DefaultRequestHandler.create(AgentExecutor, TaskStore, QueueManager, PushNotificationConfigStore, MainEventBusProcessor, Executor, Executor)`、`A2AObservabilityNames` diff --git a/env.example b/env.example index 98b0dd6b..398ef01e 100644 --- a/env.example +++ b/env.example @@ -64,3 +64,19 @@ A2AT_LLM_DETAIL_LOG_ENABLED=false # Negotiation # Selects the negotiation state store implementation. A2AT_NEGOTIATION_STATE_STORE_TYPE=in_memory + +# --- Observability (a2a-t-observability) --- +OTEL_INSTRUMENTATION_A2AT_SDK_ENABLED=true +A2AT_OBSERVABILITY_ENABLED=true +A2AT_TRACE_ENABLED=true +A2AT_METRIC_ENABLED=true +A2AT_LOG_ENABLED=true +#OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +A2AT_EXPORTER_PROTOCOL=grpc +A2AT_CONSOLE_EXPORTER=true +#OTEL_SERVICE_NAME=a2a-t-agent +#A2AT_AGENT_ROLE= +A2AT_EXTRACT_REQUEST=false +A2AT_EXTRACT_RESPONSE=false +A2AT_LOGS_PAYLOAD_ENABLED=false +A2AT_LOGS_PAYLOAD_MAX_LENGTH=4096 diff --git a/pom.xml b/pom.xml index 3ea1dcf8..2659005b 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,8 @@ a2a-t-negotiation a2a-t-client a2a-t-server + a2a-t-observability + a2a-t-observability-a2a a2a-t-corpus a2a-t-sample @@ -46,6 +48,8 @@ 3.14.0 4.36.0 2.0.17 + 1.64.0 + 1.1.0.Final 2.50.0 0.13.4 -XDaddTypeAnnotationsToSymbol=true @@ -102,6 +106,13 @@ 1.0.1 provided + + io.opentelemetry + opentelemetry-bom + ${opentelemetry.version} + pom + import +