Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ab2bf35
docs: add observability java port design spec
khunj Oct 20, 2026
1c2898c
docs: amend observability spec after feasibility review
khunj Oct 20, 2026
25a17a4
docs: resolve convergence issues in observability spec
khunj Oct 20, 2026
cd3c116
docs: add observability core implementation plan (plan A)
khunj Oct 21, 2026
e163691
chore: ignore .superpowers scratch dir
khunj Oct 21, 2026
ef4df62
feat(observability): add observability module skeletons and dependenc…
khunj Oct 21, 2026
71261c2
feat(observability): add TelemetryHook SPI to core
khunj Oct 21, 2026
0600a7b
feat(observability): add A2ATObservabilityConfig
khunj Oct 21, 2026
07ffa73
feat(observability): add attribute constants and metadata extractors
khunj Oct 21, 2026
41e1404
feat(observability): add ObservabilityRuntime switches and orphan con…
khunj Oct 21, 2026
f7015af
feat(observability): add manual span API
khunj Oct 21, 2026
4f1ad5f
feat(observability): add propagation and payload channels
khunj Oct 21, 2026
f29be98
feat(observability): add LLM usage stash, token metric and negotiatio…
khunj Oct 21, 2026
4e6b0f4
feat(observability): add structured logs, console exporters and OTel …
khunj Oct 21, 2026
b5af415
fix(observability): pin a2a-java versions in a2a module to unbreak sa…
khunj Oct 21, 2026
a761958
feat(observability): wire TelemetryHook into client and server facades
khunj Oct 21, 2026
f4a6d76
style(observability): apply palantir formatting to new observability …
khunj Oct 21, 2026
de46085
fix(observability): guard hook callbacks, avoid global poisoning, fix…
khunj Oct 21, 2026
c0b891b
docs: record final-review deferred items as Plan B inputs
khunj Oct 21, 2026
dc6e770
docs: record TelemetryOperation.close guard as Plan B input
khunj Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
10 changes: 10 additions & 0 deletions a2a-t-bom/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@
<artifactId>a2a-t-server</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>net.openan.a2a-t.sdk</groupId>
<artifactId>a2a-t-observability</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>net.openan.a2a-t.sdk</groupId>
<artifactId>a2a-t-observability-a2a</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
222 changes: 168 additions & 54 deletions a2a-t-client/src/main/java/net/openan/a2at/sdk/client/A2ATClient.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<Map<String, String>> messages, Map<String, Object> schema, Double temperature, Integer maxTokens) {
return new LLMResponse("{}", "mock", Map.of(), Map.of());
}
}

static final class RecordingHook implements TelemetryHook {
final List<String> started = new ArrayList<>();
final List<String> succeeded = new ArrayList<>();
final List<String> 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.<role>.<method>}. */
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) {}
};
}
Original file line number Diff line number Diff line change
@@ -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() {}
};
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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<String> started = new ArrayList<>();
List<String> 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"));
}
}
50 changes: 50 additions & 0 deletions a2a-t-observability-a2a/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>net.openan.a2a-t.sdk</groupId>
<artifactId>a2a-t-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>a2a-t-observability-a2a</artifactId>
<name>a2a-t Observability A2A</name>
<description>Protocol-level observability decorators for a2a-java (transport, request handler, executor, push sender). Filled by the observability-a2a plan.</description>
<dependencies>
<dependency>
<groupId>net.openan.a2a-t.sdk</groupId>
<artifactId>a2a-t-observability</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-sdk-client-transport-spi</artifactId>
<version>${a2a-java.version}</version>
</dependency>
<dependency>
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-sdk-spec</artifactId>
<version>${a2a-java.version}</version>
</dependency>
<dependency>
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-sdk-server-common</artifactId>
<version>${a2a-java.version}</version>
</dependency>
<dependency>
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-sdk-jsonrpc-common</artifactId>
<version>${a2a-java.version}</version>
</dependency>
<dependency>
<groupId>org.a2aproject.sdk</groupId>
<artifactId>a2a-java-sdk-transport-rest</artifactId>
<version>${a2a-java.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** Protocol-level observability decorators for a2a-java. */
@NullMarked
package net.openan.a2at.sdk.observability.a2a;

import org.jspecify.annotations.NullMarked;
Loading
Loading